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/webmay 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, andPartial<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
- Resolve conflicts by preserving SPA adapters and Go boot-state behavior.
- Audit the rebased changes for new
next/*imports, mocks, server component fetches,redirect(),notFound(),cookies(), andheaders(). - 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.
- Add or update focused tests before broad verification.
- 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:
- Capture the old base:
old_main="$(git rev-parse origin/main)". - Fetch and rebase onto the new
origin/main. - Inspect only newly merged main commits:
git log --oneline "$old_main"..origin/main. - For web files touched by those commits, scan for
next/*, server-data, route-loading, and boot-payload regressions before changing code. - Migrate only when the rebased branch reintroduces old Next runtime patterns or conflicts with the Go-served SPA contract.
- 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.tsxorlayout.tsx. - Pass it to a client component as props.
- Hydrate Zustand with
<StateProvider initialState={...}>. - Use
redirect()ornotFound()for route decisions.
Migrate that as follows:
- Identify the exact store fields the page needs before first paint.
- Add route classification or params in
apps/backend/internal/webapp/routes.goif Go needs to distinguish the route. - Add the data to
apps/backend/cmd/kandev/boot_state.go. Prefer backend services directly over self-calling HTTP APIs. - 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.
- 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.
- Update the SPA route/client bootstrap so it skips its client fetch when the store already has boot data.
- 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, orapps/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.