Files
kandev/docs/nextjs-spa-migration.md
T
2026-06-22 15:36:46 +01:00

5.7 KiB

Next.js to Go-served SPA migration guide

Use this guide when rebasing or finishing an in-progress PR that was written against the old Next.js web runtime. The target architecture is ADR-0020: production serves a Vite React SPA from the Go backend, and Go injects the boot payload needed for first paint.

Target contract

  • Production does not run a Next.js server or any Node.js web runtime.
  • apps/web may still use Node tooling for Vite, TypeScript, linting, tests, and development.
  • The Go backend serves the SPA shell, static Vite assets, APIs, WebSockets, and /api/v1/app-state.
  • Go injects window.__KANDEV_BOOT_PAYLOAD__ before React mounts. The payload contains route metadata, runtime config, and Partial<AppState> initial state.
  • Data that was visible on first paint in a Next server component must move to the Go boot payload or a route bootstrap that can skip fetching when the store is already hydrated.

Rebase workflow

  1. Resolve conflicts by preserving SPA adapters and Go boot-state behavior.
  2. Audit the rebased changes for new next/* imports, mocks, server component fetches, redirect(), notFound(), cookies(), and headers().
  3. For each new store read added by the incoming PR, decide whether it affects first paint. If it does, add that data to the Go boot payload.
  4. Add or update focused tests before broad verification.
  5. Re-run the Next audit command before handing off.

Rebase loop checklist

Use this loop when bringing an older branch across the SPA migration boundary:

  1. Capture the old base: old_main="$(git rev-parse origin/main)".
  2. Fetch and rebase onto the new origin/main.
  3. Inspect only newly merged main commits: git log --oneline "$old_main"..origin/main.
  4. For web files touched by those commits, scan for next/*, server-data, route-loading, and boot-payload regressions before changing code.
  5. Migrate only when the rebased branch reintroduces old Next runtime patterns or conflicts with the Go-served SPA contract.
  6. Run focused E2E for the touched area; broaden only when the touched area is shared routing, boot payload, or app shell behavior.

Useful audit commands:

rtk rg -n 'from "next/|from '\''next/|vi\.mock\("next/|next/link|next/navigation|next/image|next/dynamic|next/headers|next-themes|styled-jsx|eslint-config-next|next\.config' apps/web apps/packages/ui apps/pnpm-lock.yaml apps/package.json
rtk rg -n 'redirect\(|notFound\(|cookies\(|headers\(|StateProvider initialState|searchParams|params:' apps/web/app

The first command should have no production next/* imports or tests mocking Next modules. Comments and unrelated variable names are acceptable only after manual review.

Import replacements

Old import Replacement
next/link @/components/routing/app-link
next/navigation @/lib/routing/client-router
next/image @/components/routing/app-image
next/dynamic @/lib/routing/client-dynamic
next-themes @/components/theme/app-theme

When replacing next/navigation in tests, mock @/lib/routing/client-router, not the old Next module.

If a component needs route params from a new dynamic SPA route, update paramsForPath in apps/web/lib/routing/client-router.ts and add coverage in apps/web/lib/routing/client-router.test.ts.

Migrating server-side data

Old Next pages often did this:

  • Fetch request-time data in app/**/page.tsx or layout.tsx.
  • Pass it to a client component as props.
  • Hydrate Zustand with <StateProvider initialState={...}>.
  • Use redirect() or notFound() for route decisions.

Migrate that as follows:

  1. Identify the exact store fields the page needs before first paint.
  2. Add route classification or params in apps/backend/internal/webapp/routes.go if Go needs to distinguish the route.
  3. Add the data to apps/backend/cmd/kandev/boot_state.go. Prefer backend services directly over self-calling HTTP APIs.
  4. If an HTTP handler already returns the same data shape, extract a small DTO mapper in that backend package and reuse it from both the handler and the boot-state builder.
  5. Keep JSON shape aligned with the frontend store/API types. If the existing API shape is snake_case, the boot payload should use the same shape unless the store already expects otherwise.
  6. Update the SPA route/client bootstrap so it skips its client fetch when the store already has boot data.
  7. Add tests for the Go mapper or boot builder, and a frontend test proving the client does not refetch when boot state is present.

Optional or below-the-fold data may still fetch after mount, but do not regress routes that previously rendered useful data without an initial loading spinner.

Routing and redirects

  • Add browser route rendering in apps/web/src/spa-routes.tsx, apps/web/src/settings-routes.tsx, or apps/web/src/office-routes.tsx.
  • Use useRouter().replace() for client-side redirect behavior.
  • Move server-only gating that must happen before first paint into Go boot-state route logic or expose enough boot state for the SPA to render the correct initial screen.
  • Replace notFound() with the route fallback pattern already used by the SPA route modules, or return an explicit load/error state from the client route.

Verification checklist

Run focused tests for the files you touched, then at minimum:

rtk go test ./cmd/kandev ./internal/webapp
rtk pnpm --dir apps --filter @kandev/web typecheck
rtk pnpm --dir apps --filter @kandev/web lint

For data-hydration changes, include one backend test for the boot payload or DTO shape and one frontend test that starts with hydrated store state.