OverzichtDesign mock only
AN
Migration guide · v1

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.

01

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 dev tot rules‑lib parity bevestigd is.
  • Styling: Tailwind v4 (CSS‑first, geen tailwind.config.js) blijft. Next.js ondersteunt v4 via @tailwindcss/postcss.
02

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 prettier

Verwijder de gegenereerde demo‑content in src/app/page.tsx en src/app/globals.css voordat je tokens overzet.

03

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 StartNext.js App RouterNotes
src/routes/__root.tsxsrc/app/layout.tsxRoot <html>/<body>, providers, head links
src/routes/index.tsxsrc/app/page.tsxStatische home
src/routes/planning.tsxsrc/app/planning/page.tsx1‑op‑1
src/routes/klanten.tsxsrc/app/klanten/layout.tsxLayout met <Outlet/> → {children}
src/routes/klanten.index.tsxsrc/app/klanten/page.tsxLijst-view
src/routes/klanten.$id.tsxsrc/app/klanten/[id]/layout.tsxDynamic segment $id → [id]
src/routes/klanten.$id.index.tsxsrc/app/klanten/[id]/page.tsxOverzicht‑tab
klanten.$id.klus.$klusId.tsxklanten/[id]/klus/[klusId]/page.tsxNested dynamic
portaal.*.tsxsrc/app/portaal/*/page.tsxSubtree
notFoundComponentnot-found.tsxPer segment of root
errorComponenterror.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.

04

Navigation rewrites

Vervang @tanstack/react-router imports door next/link en next/navigation.

// before
import { Link, useParams, useNavigate } from "@tanstack/react-router";
<Link to="/klanten/$id" params={{ id }}>…</Link>;

// after
import Link from "next/link";
import { useParams, useRouter, usePathname } from "next/navigation";
<Link href={`/klanten/${id}`}>…</Link>;

Type‑safe params verdwijnen — vervang door een eigen routes.ts helper:

// src/lib/routes.ts
export const routes = {
  klant: (id: string) => `/klanten/${id}` as const,
  klus:  (id: string, k: string) => `/klanten/${id}/klus/${k}` as const,
};
05

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>
  );
}
06

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 stable

Voor 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.

07

Server-side: createServerFn → Server Actions / Route Handlers

TanStack StartNext.js equivalentWanneer
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/*.tssrc/app/api/*/route.tsWebhooks, public REST, cron
requireSupabaseAuth middlewaremiddleware.ts + auth() helperJWT 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}`);
}
08

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.

09

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.

10

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] },
  };
}
11

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 --noEmit

Schrijf migrate-routes.mjs als een glob + regex die klanten.$id.klus.$klusId.tsx uitvouwt naar klanten/[id]/klus/[klusId]/page.tsx.

12

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=always

Reverse‑proxy met Caddy/Nginx voor TLS. Voor static assets: .next/static en public/ serveren vanaf edge cache (optioneel).

13

Checklist vóór go-live

  • Alle $param bestanden hernoemd naar [param].
  • Geen @tanstack/react-router meer in package.json.
  • Elk client‑hook bestand begint met "use client".
  • useSyncExternalStore hydration getest (geen mismatch warnings).
  • Date/locale formatting deterministisch (UTC of suppressHydrationWarning).
  • next build groen, next start draait 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.