# Routing

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

- Skill: `blyatiful1/routing` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add blyatiful1/routing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/blyatiful1/routing/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: blyatiful1 (https://skillmd.com/u/blyatiful1)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/blyatiful1/routing

---


# 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

1. 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.
2. 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.
3. 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.
4. 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.
5. Route protection lives in `proxy.ts` exporting `proxy(request)` — `middleware.ts` is deprecated in Next 16.
6. 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`:

```tsx
// 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.

```tsx
// 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.

