experiment(web): migrate from Next.js to TanStack Start#618
experiment(web): migrate from Next.js to TanStack Start#618adriangohjw wants to merge 28 commits into
Conversation
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>
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
left a comment
There was a problem hiding this comment.
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:L40 — script-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:L19 — routerOptions: 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 beforeLoad → getSession() 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
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>
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/webapp 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):toStringsource 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.react-server-dom. TanStack Start was unaffected by all of the above CVEs."use client"causeswindow is undefinederrors, broken effects, and silent server value leaks into client bundles. Debugging "where is this component running?" is a persistent tax."use client"directive. All components are client by default. Server execution is opt-in via explicitcreateServerFnor routeserver.handlers— the boundary is something you draw, not something inferred from file location.next dev --turbopackcold-starts in 5–10 s on a medium codebase; production builds still fall back to webpack in stable Next.js; config spansnext.config.js, Turbopack flags, and webpack escape hatches.vite.config.tsis the entire build config. (Turbopack does win past ~10k modules, but we are nowhere near that.)node .output/server/index.mjs.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) tosrc/routes/**(TanStack Router file-based routes), with_authed/_publicpathless layout routes replacing route groups. API routes move fromsrc/app/api/**/route.tstosrc/routes/api/**.tsusingcreateFileRoute(...).server.handlers.src/server/session.server.tscarries the iron-session logic that previously lived in Next middleware-adjacent code.Breaking Changes
next dev --turbopack→vite dev,next start→node .output/server/index.mjs, build output.next/→.output/.VITE_*(validated via@t3-oss/env-core) instead ofNEXT_PUBLIC_*..env.exampleupdated; any deploy pipeline / IaC referencingNEXT_PUBLIC_*needs the rename.next/fontin favour of@fontsource-variable/inter+@fontsource/ibm-plex-mono.vercel-buildis preserved as an alias but emits a Nitro Node build.Features:
@tanstack/react-routerwith generatedsrc/routeTree.gen.ts.createFileRouteserver handlers forapi/trpc/$,api/health,api/auth/sign-out.@vitejs/plugin-react+@rolldown/plugin-babelwithreactCompilerPreset().Improvements:
vite.config.tsis the single source of build config — replacesnext.config.js, Turbopack flags, and the previousvite-tsconfig-pathsplugin (now nativeresolve.tsconfigPaths: true).src/types/nextjs.tsdeleted).@storybook/nextjs-viteto@storybook/react-vite— one fewer Next-coupled dependency.'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:
__dirnamewithimport.meta.urlderivation in configs.email-addressesto 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 generatedrouteTree.gen.ts.pnpm test— unit tests pass under Vitest.pnpm e2e— Playwright smoke test passes againstvite dev --port 3111.pnpm build && pnpm start— production server boots from.output/server/index.mjsand serves all routes./api/trpc/$./api/auth/sign-outclears it;_authedroutes redirect unauthenticated users.pnpm storybookbuilds 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— replacesnext/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.