Skip to content

experiment(web): migrate from Next.js to TanStack Start#618

Draft
adriangohjw wants to merge 28 commits into
mainfrom
migrate-web-to-tanstack-start
Draft

experiment(web): migrate from Next.js to TanStack Start#618
adriangohjw wants to merge 28 commits into
mainfrom
migrate-web-to-tanstack-start

Conversation

@adriangohjw

@adriangohjw adriangohjw commented May 17, 2026

Copy link
Copy Markdown
Contributor

Warning

POC / exploratory experiment — this is not a production PR and does not need review. It's here to document the migration approach and motivations for discussion.

Problem

The apps/web app was on Next.js App Router. While Next.js is a good default for content-heavy projects, the trade-offs do not fit how we actually build apps at OGP. Switching to TanStack Start (Vite + TanStack Router):

Motivation Next.js pain TanStack Start improvement
OGP apps are client-heavy, not content-heavy RSC streaming, PPR, ISR, and edge image optimisation are Next's headline wins — all targeting content workloads. For interactive apps (FormSG admin, Plumber, Pay.sg dashboards, Postman) they add RSC runtime bundle weight and server/client boundary overhead without paying back. Client-first SPA-style rendering with SSR on first load. No RSC runtime cost for apps that don't need it.
Less framework magic, fewer footguns 2025 saw multiple high-severity CVEs rooted in RSC machinery: CVE-2025-55182 (unauthenticated RCE via Flight deserialization), CVE-2025-55183 (toString source disclosure on server functions), CVE-2025-67779 (source leak via error serialization), CVE-2025-29927 (middleware auth bypass). The pattern is structural: the React Flight protocol is a custom serialization layer that has proven hard to secure. Does not embed react-server-dom. TanStack Start was unaffected by all of the above CVEs.
Client-default, explicit server boundaries App Router makes every component a Server Component by default. Forgetting "use client" causes window is undefined errors, broken effects, and silent server value leaks into client bundles. Debugging "where is this component running?" is a persistent tax. No "use client" directive. All components are client by default. Server execution is opt-in via explicit createServerFn or route server.handlers — the boundary is something you draw, not something inferred from file location.
Better Vite DX next dev --turbopack cold-starts in 5–10 s on a medium codebase; production builds still fall back to webpack in stable Next.js; config spans next.config.js, Turbopack flags, and webpack escape hatches. Vite 8 cold-starts in <1 s; HMR is near-instant; a single vite.config.ts is the entire build config. (Turbopack does win past ~10k modules, but we are nowhere near that.)
Easier to self-host outside Vercel Next is technically deployable anywhere, but ISR, image optimisation, edge middleware, and preview deploys are de facto Vercel-grade only. OGP deploys to GovTech AWS infra — those Vercel-tier features were never available to us. Built on Nitro (same engine as Nuxt) with first-class adapters for Node, Bun, Deno, Cloudflare, Netlify, and AWS Lambda. Production server is a plain node .output/server/index.mjs.
TanStack integrations are first-class tRPC + React Query work in Next, but wiring SSR dehydration/hydration, typed search params, and redirect handling is manual glue code. TanStack Router, Query, and Start share one team and one design. The router ↔ query SSR integration handles dehydration/hydration, query streaming, and typed redirect() handoff automatically. @trpc/tanstack-react-query (already in this repo) plugs in natively.

Solution

Replace Next.js App Router with TanStack Start on Vite, while keeping the rest of the stack (tRPC, TanStack Query, iron-session, Drizzle, OUI, Tailwind) untouched. Routes move from src/app/** (App Router file conventions) to src/routes/** (TanStack Router file-based routes), with _authed / _public pathless layout routes replacing route groups. API routes move from src/app/api/**/route.ts to src/routes/api/**.ts using createFileRoute(...).server.handlers. src/server/session.server.ts carries the iron-session logic that previously lived in Next middleware-adjacent code.

Breaking Changes

  • Yes - this PR contains breaking changes
    • Dev/start commands: next dev --turbopackvite dev, next startnode .output/server/index.mjs, build output .next/.output/.
    • Environment variable convention: client-exposed vars are now VITE_* (validated via @t3-oss/env-core) instead of NEXT_PUBLIC_*. .env.example updated; any deploy pipeline / IaC referencing NEXT_PUBLIC_* needs the rename.
    • Fonts: dropped next/font in favour of @fontsource-variable/inter + @fontsource/ibm-plex-mono.
    • No longer Vercel-runtime-aware; vercel-build is preserved as an alias but emits a Nitro Node build.

Features:

  • File-based, fully type-safe routing via @tanstack/react-router with generated src/routeTree.gen.ts.
  • createFileRoute server handlers for api/trpc/$, api/health, api/auth/sign-out.
  • React Compiler enabled via @vitejs/plugin-react + @rolldown/plugin-babel with reactCompilerPreset().

Improvements:

  • Sub-second dev server cold start; near-instant HMR via Vite 8 (Rolldown-compatible).
  • vite.config.ts is the single source of build config — replaces next.config.js, Turbopack flags, and the previous vite-tsconfig-paths plugin (now native resolve.tsconfigPaths: true).
  • tRPC context simplified: no longer threading through Next-specific request types (src/types/nextjs.ts deleted).
  • Storybook moved from @storybook/nextjs-vite to @storybook/react-vite — one fewer Next-coupled dependency.
  • Removed 11 'use client' no-op directives and deleted dead RSC-era tRPC callers (rsc-caller.ts, server.tsx) — reduces bundler surface and removes misleading Next.js artefacts.

Bug Fixes:

  • ESM correctness: replaced __dirname with import.meta.url derivation in configs.
  • CJS interop: default-imported email-addresses to fix Vite's stricter ESM resolution.

Tests

  • pnpm dev — landing page, sign-in flow, and authed admin page render and route correctly.
  • pnpm typecheck — passes, including generated routeTree.gen.ts.
  • pnpm test — unit tests pass under Vitest.
  • pnpm e2e — Playwright smoke test passes against vite dev --port 3111.
  • pnpm build && pnpm start — production server boots from .output/server/index.mjs and serves all routes.
  • tRPC: a query and a mutation succeed via /api/trpc/$.
  • Auth: sign-in sets the iron-session cookie; /api/auth/sign-out clears it; _authed routes redirect unauthenticated users.
  • Storybook: pnpm storybook builds and renders the landing + sign-in stories.

New scripts:

  • start : node .output/server/index.mjs — runs the Nitro Node build.

New dependencies:

  • @tanstack/react-router — type-safe file-based router.
  • @tanstack/react-start — full-stack framework layer (Vite plugin, server functions, Nitro build).
  • @t3-oss/env-core — framework-agnostic replacement for @t3-oss/env-nextjs.
  • @fontsource-variable/inter, @fontsource/ibm-plex-mono — replaces next/font.
  • @unpic/react — framework-agnostic <Image> component; replaces raw <img> tags to get lazy loading and aspect-ratio handling for free.

New dev dependencies:

  • @vitejs/plugin-react, @tailwindcss/vite, @tanstack/router-plugin — Vite plugins for React, Tailwind, and route-tree generation.
  • @babel/core, @rolldown/plugin-babel — required to run the React Compiler preset under Rolldown.

Removed dependencies:

  • next, nextjs-toploader, @t3-oss/env-nextjs, @storybook/nextjs-vite, vite-tsconfig-paths.

adriangohjw and others added 17 commits May 17, 2026 13:13
Replace Next.js App Router with TanStack Start (v1.168) + TanStack Router
(v1.170), preserving identical public behaviour — same URLs, HTTP/tRPC
contracts, auth flow, and look-and-feel.

Key changes:
- Add vite.config.ts with tanstackStart(), replace next.config.js
- Replace src/app/ page/layout files with src/routes/ file-based routes
- Replace next/headers session with iron-session sealData/unsealData via
  @tanstack/react-start/server cookie helpers
- Replace next/navigation (useRouter, redirect) with TanStack Router
  equivalents; auth guards use beforeLoad + throw redirect()
- Replace next/link and next/image with TanStack Router Link and plain img
- Replace @storybook/nextjs-vite with @storybook/react-vite
- Replace nuqs/adapters/next/app with nuqs/adapters/react
- Replace next/font with @fontsource-variable/inter and @fontsource/ibm-plex-mono
- Add tanstack-start catalog to pnpm-workspace.yaml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bumps vite to ^8.0.13 and @vitejs/plugin-react to ^6.0.2 (required for Vite 8).
Also bumps @tailwindcss/vite to ^4.3.0 and vitest catalog to 4.1.6 to satisfy
Vite 8 peer ranges without warnings.

Rolldown (Vite 8's bundler) enforces TanStack Start's import-protection plugin
more strictly than Rollup did, exposing two issues:

1. session.ts was statically importing @tanstack/react-start/server at module
   level, which the import-protection plugin blocks in the client bundle. Fixed
   by splitting into session.server.ts (server-only cookie logic) and a thin
   createServerFn wrapper in session.ts. API routes and tRPC now import from
   session.server directly; route beforeLoad/loader use the createServerFn path.

2. TanStack Start plugins pulled from vite.config.ts conflict with Storybook's
   multi-entry build under Rolldown. Fixed by filtering all tanstack* plugins
   out of the Storybook viteFinal config (stories only need React component
   context, not TanStack Start routing).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Added "coverage", "dist", and "storybook-static" to the exclude list in tsconfig.json to prevent TypeScript from processing these directories.
- Upgraded @babel/core to version 7.29.0 and added @rolldown/plugin-babel as a dependency.
- Updated Vite configuration to use @rolldown/plugin-babel for Babel integration.
- Adjusted package.json to reflect the new Babel version and plugin.
- Updated pnpm-lock.yaml to include new versions and dependencies.
…t/Vite

Rename NEXT_PUBLIC_ prefixed env vars to VITE_, switch from @t3-oss/env-nextjs to @t3-oss/env-core, and update related configs across web app and logging packages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ve.tsconfigPaths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…resolve.tsconfigPaths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@adriangohjw adriangohjw self-assigned this May 17, 2026
adriangohjw and others added 5 commits May 17, 2026 17:01
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
TanStack Start has no server/client component model, so the pragma is a no-op.
Also updates the trpc.ts resHeaders comment to reference server callers and route
loaders instead of RSC.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
rsc-caller.ts and server.tsx export RSC-specific callers (createCaller,
HydrateClient, etc.) that are never imported anywhere. These patterns don't
apply in TanStack Start where data loading happens via route loaders.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds @unpic/react and replaces all raw <img> tags with <Image> per the
TanStack Start migration guide. Lazy loading and aspect-ratio handling come
for free; future raster images will use the same API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Delete files that were unused after the Next.js → TanStack Start
  migration: proxy.ts, instrumentation.ts, trpc/context.ts,
  trpc/types.ts, postcss.config.js, back-button.tsx
- Remove unused deps: @acme/common, @acme/validators, lodash-es,
  type-fest, @tanstack/router-plugin, @types/lodash-es,
  storybook-addon-vite-mock, tw-animate-css
- Remove unused export on SignInWizardContext
- Add src/server.ts to init Datadog tracer at server startup
  (replaces the Next.js instrumentation.ts register() hook)
- Add src/start.ts with CSP request middleware via createStart
  (CSP was present in proxy.ts on main but never wired up)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@adriangohjw adriangohjw left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This PR migrates apps/web from Next.js App Router to TanStack Start (Vite + TanStack Router). All existing routes are accounted for — _authed / _public pathless layouts replace route groups, iron-session is rewired to work with createServerFn + getCookie/setCookie from TanStack's server context, API routes become server.handlers, and RSC-era dead code is cleanly removed. The split between session.ts (serializable createServerFn wrapper, for route beforeLoad) and session.server.ts (full Session with save/destroy, for API handlers and tRPC) is a sound pattern.

Overall assessment: Functional parity is largely intact. Two removals have real security/stability implications and should be addressed before this pattern gets adopted in production apps.


Findings

issue (security): apps/web/src/start.ts:L40script-src now permanently includes 'unsafe-inline'. The deleted proxy.ts generated a per-request nonce (crypto.randomUUID()) and passed 'nonce-${nonce}' into CSP instead, which is far stricter — 'unsafe-inline' allows any inline script, meaning a reflected-XSS payload doesn't need to bypass a nonce. The comment acknowledges the tradeoff: TanStack Start emits inline hydration scripts and nonce threading is non-trivial. This is worth tracking: if TanStack Start gains a nonce injection API, this should be updated. For now the change is intentional but is a genuine security surface expansion compared to the baseline.

Confidence: high


issue: apps/web/next.config.js (deleted) — proxyClientMaxBodySize: '2mb' is gone with no equivalent in the new stack. The comment in the old proxy.ts was explicit: "This config plus proxyClientMaxBodySize in next.config.js will ensure that spammy large requests are blocked. If you require a larger body size, increase proxyClientMaxBodySize accordingly." Without a body cap, a client can POST an arbitrarily large body to /api/trpc/$ and exhaust server memory. Nitro (the runtime under TanStack Start) supports a maxRequestBodySize option. In vite.config.ts, this can be set via:

tanstackStart({
  server: {
    maxRequestBodySize: 2 * 1024 * 1024, // 2 MB
  },
})

Confidence: high


issue (UX, non-blocking): apps/web/src/app/layout.tsx (deleted) — NextTopLoader (the visual navigation progress bar) was removed with no replacement. Users now get zero feedback during route transitions that involve server-fn round-trips (beforeLoad, loader). TanStack Router exposes useRouterState({ select: s => s.isLoading }) which can drive a thin progress bar without needing an external package. Not a correctness issue, but noticeable on slower connections.

Confidence: high


question: apps/web/src/app/provider.tsx:L19routerOptions: never on the react-aria RouterConfig. The old code typed this as NonNullable<Parameters<ReturnType<typeof useRouter>['push']>[1]>, which let react-aria <Link> components pass Next.js-specific router options through. Typing it never means no router options can ever be forwarded from react-aria links. Is this intentional (TanStack Router doesn't have an equivalent second-argument options object for navigate)? If yes, a comment explaining the deliberate narrowing would help future readers.

Confidence: medium


note: apps/web/src/server/session.ts:L63 — the createServerFn wrapper for getSession does a dynamic import of ./session.server on every call. This is necessary to keep getCookie/setCookie server-only, but means the dynamic import overhead hits every beforeLoad that checks auth. This is an acceptable pattern for now, but worth knowing about if profiling reveals it as a hot path.

Confidence: medium


note: apps/web/src/app/(public)/sign-in/_components/wizard/email/verification-step.tsx:L57 — post-OTP success now calls void navigate({ to: AUTHED_ROOT_ROUTE }) instead of the old router.refresh(). On the old stack, refresh() re-ran the AuthedLayout server component (which called getSession server-side) and would redirect if the session wasn't established. On the new stack, the navigation triggers _authed.tsx's beforeLoadgetSession() server fn → redirect if not authed. The intent is equivalent, but the new approach is arguably cleaner: auth is enforced at the route level on every entry rather than being tied to the refresh lifecycle.

Confidence: high

adriangohjw and others added 6 commits May 17, 2026 18:38
Shows a thin loading bar at the top of the page during TanStack Router navigations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Prevents large payloads from exhausting server memory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Expands the CSP comment to explain why 'unsafe-inline' is needed and what
would be required to revert to nonce-based policy. Adds a note on why
routerOptions is typed as never for react-aria's RouterConfig.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@adriangohjw adriangohjw changed the title chore(web): migrate from Next.js to TanStack Start experiment(web): migrate from Next.js to TanStack Start May 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant