TanStack Start → Next.js 15 (App Router)
End-to-end technisch draaiboek om deze mock om te bouwen naar een lokale, zelf-gehoste Next.js build met React 19, App Router, RSC, Server Actions en Route Handlers.
Doelstack & runtime aannames
- Next.js ≥ 15.x (App Router), React 19, Node ≥ 20.11 (LTS).
- Runtime: standalone Node server (
output: "standalone") — geen Vercel / Cloudflare specifieke wrappers. - Package manager: pnpm 9 (workspaces‑ready). Bun werkt ook maar mist nog complete RSC streaming‑parity.
- Bundler: standaard Webpack tijdens migratie; Turbopack alleen voor
next devtot rules‑lib parity bevestigd is. - Styling: Tailwind v4 (CSS‑first, geen
tailwind.config.js) blijft. Next.js ondersteunt v4 via@tailwindcss/postcss.
Project skeleton bootstrap
pnpm dlx create-next-app@latest woodstock-next \
--ts --app --src-dir --tailwind --eslint \
--import-alias "@/*" --no-turbopack
cd woodstock-next
pnpm add @tanstack/react-query zod clsx lucide-react
pnpm add -D @types/node@20 prettierVerwijder de gegenereerde demo‑content in src/app/page.tsx en src/app/globals.css voordat je tokens overzet.
Routing-mapping (file-based → App Router)
TanStack Start gebruikt dot‑separated src/routes/*; Next.js App Router gebruikt directories met page.tsx / layout.tsx. Mapping‑tabel:
| TanStack Start | Next.js App Router | Notes |
|---|---|---|
src/routes/__root.tsx | src/app/layout.tsx | Root <html>/<body>, providers, head links |
src/routes/index.tsx | src/app/page.tsx | Statische home |
src/routes/planning.tsx | src/app/planning/page.tsx | 1‑op‑1 |
src/routes/klanten.tsx | src/app/klanten/layout.tsx | Layout met <Outlet/> → {children} |
src/routes/klanten.index.tsx | src/app/klanten/page.tsx | Lijst-view |
src/routes/klanten.$id.tsx | src/app/klanten/[id]/layout.tsx | Dynamic segment $id → [id] |
src/routes/klanten.$id.index.tsx | src/app/klanten/[id]/page.tsx | Overzicht‑tab |
klanten.$id.klus.$klusId.tsx | klanten/[id]/klus/[klusId]/page.tsx | Nested dynamic |
portaal.*.tsx | src/app/portaal/*/page.tsx | Subtree |
notFoundComponent | not-found.tsx | Per segment of root |
errorComponent | error.tsx (client comp) | Required: 'use client' + reset() |
Belangrijk: alle params worden { params: Promise<{ id: string }> } in Next 15 — je moet ze awaiten in server components.
Layout & providers (__root.tsx → app/layout.tsx)
// src/app/layout.tsx (Server Component)
import "./globals.css";
import { Providers } from "./providers";
export const metadata = {
title: "Woodstock Planner",
description: "Warm Accents Planner",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="nl">
<body className="bg-paper text-ink">
<Providers>{children}</Providers>
</body>
</html>
);
}
// src/app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TenantProvider } from "@/lib/tenant-context";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [qc] = useState(() => new QueryClient());
return (
<QueryClientProvider client={qc}>
<TenantProvider>{children}</TenantProvider>
</QueryClientProvider>
);
}Client state: tenant-context & klus-store
Beide gebruiken useSyncExternalStore / document.documentElement.style. Markeer ze als "use client" en zorg dat ze niet in een Server Component file geïmporteerd worden op module‑scope.
// src/lib/klus-store.ts
"use client";
// rest unchanged — useSyncExternalStore is React 19 stableVoor SSR‑safe hydration: render initial data via een Server Component die props doorgeeft aan een Client wrapper, of forceer dynamic = "force-dynamic" op de route segment.
Server-side: createServerFn → Server Actions / Route Handlers
| TanStack Start | Next.js equivalent | Wanneer |
|---|---|---|
createServerFn({ method:'POST' }) | Server Action ('use server') | Form submits, mutations vanuit React |
createServerFn({ method:'GET' }) | Server Component fetch + cache() | Initial data load |
src/routes/api/public/*.ts | src/app/api/*/route.ts | Webhooks, public REST, cron |
requireSupabaseAuth middleware | middleware.ts + auth() helper | JWT check globally |
// src/app/klanten/[id]/actions.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
const Schema = z.object({ id: z.string(), titel: z.string().min(1) });
export async function createKlus(input: unknown) {
const data = Schema.parse(input);
// ...db write
revalidatePath(`/klanten/${data.id}`);
}Data fetching: ensureQueryData → RSC + use()
// Server Component
import { getKlus } from "@/lib/klus.server";
export default async function Page({ params }: { params: Promise<{ klusId: string }> }) {
const { klusId } = await params;
const klus = await getKlus(klusId); // direct DB call op de server
return <KlusDetail initial={klus} />; // client comp met React Query hydration
}Voor mutaties die de UI updaten: gebruik useOptimistic() + Server Action i.p.v. useMutation.
Styling — Tailwind v4 in Next.js
// postcss.config.mjs
export default { plugins: { "@tailwindcss/postcss": {} } };
// src/app/globals.css
@import "tailwindcss";
@theme inline { /* tokens — 1:1 uit huidige src/styles.css */ }Webfonts via next/font i.p.v. <link> in __root.tsx — geeft automatische preloading en elimineert CLS.
Metadata, SEO & head() migratie
Vervang per route head() door metadata of generateMetadata():
export async function generateMetadata({ params }) {
const { id } = await params;
const klant = await getKlant(id);
return {
title: `${klant.naam} — Woodstock`,
openGraph: { title: klant.naam, images: [klant.cover] },
};
}Codemod & migratiescript
# 1. Verplaats routes met dot→slash mapping
node scripts/migrate-routes.mjs # eigen script (zie repo)
# 2. Strip TanStack imports
pnpm dlx jscodeshift -t codemods/tanstack-to-next.ts src/
# 3. Run Next codemods waar relevant
pnpm dlx @next/codemod@canary upgrade .
# 4. Type-check
pnpm tsc --noEmitSchrijf migrate-routes.mjs als een glob + regex die klanten.$id.klus.$klusId.tsx uitvouwt naar klanten/[id]/klus/[klusId]/page.tsx.
Lokale build, run & deploy
# next.config.mjs
export default {
output: "standalone",
reactStrictMode: true,
experimental: { reactCompiler: true },
};
# build & start
pnpm build
node .next/standalone/server.js # luistert op :3000
# systemd unit (productie)
[Service]
ExecStart=/usr/bin/node /opt/woodstock/.next/standalone/server.js
Environment=NODE_ENV=production PORT=3000
Restart=alwaysReverse‑proxy met Caddy/Nginx voor TLS. Voor static assets: .next/static en public/ serveren vanaf edge cache (optioneel).
Checklist vóór go-live
- Alle
$parambestanden hernoemd naar[param]. - Geen
@tanstack/react-routermeer inpackage.json. - Elk client‑hook bestand begint met
"use client". useSyncExternalStorehydration getest (geen mismatch warnings).- Date/locale formatting deterministisch (UTC of
suppressHydrationWarning). next buildgroen,next startdraait standalone.- 404 / error boundaries op root én op auth‑subtree.
- Tailwind tokens 1:1 overgenomen — visuele regressie diff gedraaid.
Dit document is een mock‑artefact in deze design‑preview en is geen deel van de uiteindelijke applicatie‑UI.