routing — the URL tree is architecture
Stage: Phase 6 — Build (engineering) - Reads: design/SITEMAP.md, design/SYSTEM.md - Writes: app/ route directories, loading.tsx/error.tsx/not-found.tsx per segment, app/global-not-found.tsx + app/global-error.tsx, the root-layout Speculation Rules block
Standard
- The route tree implements
design/SITEMAP.md verbatim — every listed page exists, zero orphan routes. A mismatch gets fixed in the artifact first, then the code.
- URLs are clean and human:
/work/atlas-rebrand, never /pages/work?id=3. Route-group parens never leak into URLs.
- Every segment that fetches data has a
loading.tsx whose skeleton matches the real layout (zero jump when content lands). Every segment that can fail has an error.tsx written in the site's voice with a working recovery action.
- 404, error, and 500 boundaries are designed pages — display-face headline, one line of on-brand copy, a working way out — never the framework default. The page a visitor hits by accident earns the hero's craft.
- Modals that represent content (image detail, quick view, share targets) get intercepting routes so deep links, refresh, and back-button all behave.
Process
- Read
design/SITEMAP.md. Group pages by the layout they share, not by taxonomy: (marketing) for header+footer pages, (app) for an auth-gated shell, (auth) for chromeless sign-in. Groups exist to scope layouts — a group with no own layout.tsx is noise.
- Create dynamic segments for every content collection:
[slug] for one param, [...slug] only when depth genuinely varies. Add generateStaticParams for collections known at build time.
- For each modal-with-URL in the sitemap, build the parallel + intercepting pair (below). Verify
default.tsx exists in every slot before the first build attempt.
- Design the segment UI files:
loading.tsx and error.tsx per data-bearing segment (skeletons and error surfaces come from ui-states), not-found.tsx on collection segments, app/global-not-found.tsx for unmatched URLs, and app/global-error.tsx as the root-layout safety net.
- Route protection lives in
proxy.ts exporting proxy(request) — middleware.ts is deprecated in Next 16.
- Verify:
npm run build clean (a missing slot default.tsx fails here), then click every sitemap URL plus one garbage URL in the dev server.
Dynamic segments — params is a Promise
Next 16: params and searchParams are Promises. Always await, in pages, layouts, AND generateMetadata:
// app/(marketing)/work/[slug]/page.tsx
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const project = await getProject(slug)
if (!project) notFound() // from 'next/navigation' → renders the segment's not-found.tsx
// ...
}
generateMetadata({ params }, parent) awaits the same promise. Missing data calls notFound() — never renders an empty shell.
Parallel + intercepting routes (modals)
app/
layout.tsx ← export default function Layout({ children, modal }) — slot arrives as a prop
@modal/
default.tsx ← return null. REQUIRED: Next 16 build FAILS without it
(.)photo/[id]/page.tsx ← renders as modal on soft navigation
photo/[id]/page.tsx ← renders as full page on hard nav / refresh / shared link
- Interception matchers:
(.) same level, (..) one level up, (...) from app root.
- Every parallel route slot requires
default.tsx — usually return null. This is the #1 build failure in this pattern.
- Dismiss with
router.back() in a client modal wrapper; Escape and backdrop-click both call it (a11y: focus-trap the dialog, return focus on close).
- Both renderings show the same content — the modal is a presentation upgrade, not the only door.
Speculation Rules — prerender the likely next document
next/link prefetch fetches the RSC payload (route chunk + data to the nearest loading.tsx), viewport-triggered, production only — it never prerenders the whole document. The browser's Speculation Rules API does: one JSON block in the root layout and Chromium renders the full destination — HTML, data, client JS — before the click. Non-Chromium browsers ignore the unknown script type, so next/link stays the everywhere fallback; this is a progressive upgrade, never a replacement.
// app/layout.tsx — once, inside <body>
<script type="speculationrules" dangerouslySetInnerHTML={{ __html: JSON.stringify({
prerender: [{
where: { and: [
{ href_matches: "/*" },
{ not: { href_matches: "/checkout/*" } },
{ not: { href_matches: "/auth/*" } },
{ not: { href_matches: "/logout" } },
]},
eagerness: "moderate", // hover ~200ms / pointerdown — not on sight
}],
}) }} />
- Cap eagerness at
moderate; never eager or immediate. Prerender-on-sight burns server renders on links no one clicks and skews analytics — a prerender executes the page, so pageviews double-count unless the tag defers on document.prerendering / the prerenderingchange event. Drop the whole rule to conservative (pointerdown only) when unsure.
- Exclude every stateful or side-effecting route —
/checkout/*, /auth/*, /logout, cart mutations. A speculative render must never hold inventory, spend a one-time token, or sign someone out.
- Same-origin only (the API won't cross origins). Prerendered pages count toward Core Web Vitals on activation, so a wrong prediction is wasted work, not a broken UX.
Segment UI files
Designed moments, not defaults — Framewalk turns its 404 into a playable fragment of Hollow Cartographer, not "This page could not be found."
| File |
Rules |
loading.tsx |
Auto-wraps the segment in Suspense. Skeleton mirrors the real page structure — grid stays a grid, not a centered spinner. |
error.tsx |
MUST be 'use client'. Receives { error, reset } — catches errors in the segment's children, not its own layout (the parent boundary owns that). Designed message + a real button wired to reset(). Never surface error.message raw. |
not-found.tsx |
Per-segment 404, triggered by notFound(). On-brand copy in the display face, a route back to the collection — not a dead end. |
app/global-not-found.tsx |
App-wide 404 for unmatched URLs (Next 16, still experimental — needs experimental.globalNotFound in next.config). Bypasses the root layout: supplies its own <html>/<body>, imports fonts/globals itself. Carry a signature motif; it gets screenshotted and shared. |
app/global-error.tsx |
The root layout's own safety net — the one boundary above it. MUST be 'use client' and render its own <html>/<body> (it replaces the root layout when it fires). Takes { error, reset }; production only (dev shows the overlay). Self-contained — inline styles or its own font import, since the layout that normally supplies them is what broke. |
Anti-patterns
middleware.ts — deprecated; greppable filename. Use proxy.ts (codemod: npx @next/codemod@latest rename-middleware-to-proxy .).
const { slug } = params without await — params is a Promise; greppable: params.slug outside an awaited destructure, { params }: { params: { typed as a plain object.
- A
@slot directory with no default.tsx — the build fails; fix the file, don't delete the slot.
- Framework-default 404/error screens reaching production.
- Route groups named
(pages), (routes), (components) — groups are layout scopes; name them for the shell they share.
loading.tsx = centered spinner for a card grid — skeleton must match layout or it causes a visual double-take.
[...slug] catch-all as a lazy router when SITEMAP.md names a finite page list.
href="#" anywhere in nav wiring — banned by taste; every link resolves to a real segment.
eagerness: "eager" or "immediate" on a broad Speculation Rules href_matches — prerenders links no one clicks, burns server renders, and inflates analytics; cap at moderate, and never speculate /checkout, /auth, or /logout.
global-error.tsx missing 'use client' or its own <html>/<body> — it replaces the root layout when it fires, so a bare fragment renders a blank document.
Worked example — Tidepool, port-logistics analytics route tree
Moved to references/example.md — read only when this build's case is genuinely ambiguous; the sections above are the decision material.
Composes with
Moved to references/composes.md — the handoff map; load it when orchestrating this skill against its neighbors.
1---2name: routing3description: Designs the app/ route tree for a Next.js 16 App Router site — route groups for layout scoping, dynamic segments with awaited params (they are Promises in Next 16), parallel routes with their mandatory default.tsx, intercepting routes for URL-addressable modals, per-segment loading.tsx/error.tsx/not-found.tsx plus global-not-found.tsx and global-error.tsx as designed moments, and a root-layout Speculation Rules block that prerenders the likely next document — all designed to the system, not framework defaults. Invoke during the build phase when translating design/SITEMAP.md into app/ directories, when adding a dynamic or catch-all route, when a modal needs a shareable URL, when a 404/error/loading surface still looks default, or when navigation should feel instant. Trigger phrases — "set up the routes", "dynamic route", "route group", "slug page", "modal with a URL", "intercepting route", "custom 404", "500 error page", "loading state for this page", "error page", "prerender the next page".4---56# routing — the URL tree is architecture78**Stage:** Phase 6 — Build (engineering) - **Reads:** design/SITEMAP.md, design/SYSTEM.md - **Writes:** app/ route directories, loading.tsx/error.tsx/not-found.tsx per segment, app/global-not-found.tsx + app/global-error.tsx, the root-layout Speculation Rules block910## Standard1112- The route tree implements `design/SITEMAP.md` verbatim — every listed page exists, zero orphan routes. A mismatch gets fixed in the artifact first, then the code.13- URLs are clean and human: `/work/atlas-rebrand`, never `/pages/work?id=3`. Route-group parens never leak into URLs.14- Every segment that fetches data has a `loading.tsx` whose skeleton matches the real layout (zero jump when content lands). Every segment that can fail has an `error.tsx` written in the site's voice with a working recovery action.15- 404, error, and 500 boundaries are designed pages — display-face headline, one line of on-brand copy, a working way out — never the framework default. The page a visitor hits by accident earns the hero's craft.16- Modals that represent content (image detail, quick view, share targets) get intercepting routes so deep links, refresh, and back-button all behave.1718## Process19201. Read `design/SITEMAP.md`. Group pages by the **layout they share**, not by taxonomy: `(marketing)` for header+footer pages, `(app)` for an auth-gated shell, `(auth)` for chromeless sign-in. Groups exist to scope layouts — a group with no own layout.tsx is noise.212. Create dynamic segments for every content collection: `[slug]` for one param, `[...slug]` only when depth genuinely varies. Add `generateStaticParams` for collections known at build time.223. For each modal-with-URL in the sitemap, build the parallel + intercepting pair (below). Verify `default.tsx` exists in every slot **before** the first build attempt.234. Design the segment UI files: `loading.tsx` and `error.tsx` per data-bearing segment (skeletons and error surfaces come from `ui-states`), `not-found.tsx` on collection segments, `app/global-not-found.tsx` for unmatched URLs, and `app/global-error.tsx` as the root-layout safety net.245. Route protection lives in `proxy.ts` exporting `proxy(request)` — `middleware.ts` is deprecated in Next 16.256. Verify: `npm run build` clean (a missing slot `default.tsx` fails here), then click every sitemap URL plus one garbage URL in the dev server.2627## Dynamic segments — params is a Promise2829Next 16: `params` and `searchParams` are Promises. Always `await`, in pages, layouts, AND `generateMetadata`:3031```tsx32// app/(marketing)/work/[slug]/page.tsx33export default async function Page({ params }: { params: Promise<{ slug: string }> }) {34 const { slug } = await params35 const project = await getProject(slug)36 if (!project) notFound() // from 'next/navigation' → renders the segment's not-found.tsx37 // ...38}39```4041`generateMetadata({ params }, parent)` awaits the same promise. Missing data calls `notFound()` — never renders an empty shell.4243## Parallel + intercepting routes (modals)4445```46app/47 layout.tsx ← export default function Layout({ children, modal }) — slot arrives as a prop48 @modal/49 default.tsx ← return null. REQUIRED: Next 16 build FAILS without it50 (.)photo/[id]/page.tsx ← renders as modal on soft navigation51 photo/[id]/page.tsx ← renders as full page on hard nav / refresh / shared link52```5354- Interception matchers: `(.)` same level, `(..)` one level up, `(...)` from app root.55- **Every parallel route slot requires `default.tsx`** — usually `return null`. This is the #1 build failure in this pattern.56- Dismiss with `router.back()` in a client modal wrapper; Escape and backdrop-click both call it (a11y: focus-trap the dialog, return focus on close).57- Both renderings show the same content — the modal is a presentation upgrade, not the only door.5859## Speculation Rules — prerender the likely next document6061`next/link` prefetch fetches the RSC payload (route chunk + data to the nearest `loading.tsx`), viewport-triggered, production only — it never prerenders the *whole* document. The browser's Speculation Rules API does: one JSON block in the root layout and Chromium renders the full destination — HTML, data, client JS — before the click. Non-Chromium browsers ignore the unknown script type, so `next/link` stays the everywhere fallback; this is a progressive upgrade, never a replacement.6263```tsx64// app/layout.tsx — once, inside <body>65<script type="speculationrules" dangerouslySetInnerHTML={{ __html: JSON.stringify({66 prerender: [{67 where: { and: [68 { href_matches: "/*" },69 { not: { href_matches: "/checkout/*" } },70 { not: { href_matches: "/auth/*" } },71 { not: { href_matches: "/logout" } },72 ]},73 eagerness: "moderate", // hover ~200ms / pointerdown — not on sight74 }],75}) }} />76```7778- **Cap eagerness at `moderate`; never `eager` or `immediate`.** Prerender-on-sight burns server renders on links no one clicks and skews analytics — a prerender *executes* the page, so pageviews double-count unless the tag defers on `document.prerendering` / the `prerenderingchange` event. Drop the whole rule to `conservative` (pointerdown only) when unsure.79- **Exclude every stateful or side-effecting route** — `/checkout/*`, `/auth/*`, `/logout`, cart mutations. A speculative render must never hold inventory, spend a one-time token, or sign someone out.80- Same-origin only (the API won't cross origins). Prerendered pages count toward Core Web Vitals on activation, so a wrong prediction is wasted work, not a broken UX.8182## Segment UI files8384Designed moments, not defaults — Framewalk turns its 404 into a playable fragment of *Hollow Cartographer*, not "This page could not be found."8586| File | Rules |87|------|-------|88| `loading.tsx` | Auto-wraps the segment in Suspense. Skeleton mirrors the real page structure — grid stays a grid, not a centered spinner. |89| `error.tsx` | MUST be `'use client'`. Receives `{ error, reset }` — catches errors in the segment's children, not its own layout (the parent boundary owns that). Designed message + a real button wired to `reset()`. Never surface `error.message` raw. |90| `not-found.tsx` | Per-segment 404, triggered by `notFound()`. On-brand copy in the display face, a route back to the collection — not a dead end. |91| `app/global-not-found.tsx` | App-wide 404 for unmatched URLs (Next 16, still experimental — needs `experimental.globalNotFound` in next.config). Bypasses the root layout: supplies its own `<html>`/`<body>`, imports fonts/globals itself. Carry a signature motif; it gets screenshotted and shared. |92| `app/global-error.tsx` | The root layout's own safety net — the one boundary above it. MUST be `'use client'` and render its **own** `<html>`/`<body>` (it replaces the root layout when it fires). Takes `{ error, reset }`; production only (dev shows the overlay). Self-contained — inline styles or its own font import, since the layout that normally supplies them is what broke. |9394## Anti-patterns9596- `middleware.ts` — deprecated; greppable filename. Use `proxy.ts` (codemod: `npx @next/codemod@latest rename-middleware-to-proxy .`).97- `const { slug } = params` without `await` — params is a Promise; greppable: `params.slug` outside an awaited destructure, `{ params }: { params: {` typed as a plain object.98- A `@slot` directory with no `default.tsx` — the build fails; fix the file, don't delete the slot.99- Framework-default 404/error screens reaching production.100- Route groups named `(pages)`, `(routes)`, `(components)` — groups are layout scopes; name them for the shell they share.101- `loading.tsx` = centered spinner for a card grid — skeleton must match layout or it causes a visual double-take.102- `[...slug]` catch-all as a lazy router when SITEMAP.md names a finite page list.103- `href="#"` anywhere in nav wiring — banned by taste; every link resolves to a real segment.104- `eagerness: "eager"` or `"immediate"` on a broad Speculation Rules `href_matches` — prerenders links no one clicks, burns server renders, and inflates analytics; cap at `moderate`, and never speculate `/checkout`, `/auth`, or `/logout`.105- `global-error.tsx` missing `'use client'` or its own `<html>`/`<body>` — it replaces the root layout when it fires, so a bare fragment renders a blank document.106107## Worked example — Tidepool, port-logistics analytics route tree108109Moved to `references/example.md` — read only when this build's case is genuinely ambiguous; the sections above are the decision material.110111## Composes with112113Moved to `references/composes.md` — the handoff map; load it when orchestrating this skill against its neighbors.