App Router: the filesystem is the route table

The conventions

entry role
page.tsx / index.tsx the route leaf — a page module (default-export view + loader/json/md)
layout.tsx wraps everything below this segment (Layouts)
not-found.tsx the 404 page (app root today; per-segment is wired in the router, pipeline next)
[slug]/ dynamic segment → ctx.params.slug
[[slug]]/ optional segment — matches with the param set or absent
[...path]/ catch-all → ctx.params.path (joined string)
[[...path]]/ optional catch-all — also matches zero segments
(group)/ route group — shapes the filesystem, invisible in the URL
_anything never a route — colocate components, tests, models freely

Matching priority at each level is exact static > [param] > [...catchAll], with backtracking: a static directory that dead-ends doesn't shadow a dynamic sibling.

One matcher, no drift

The same matcher drives june dev (resolved per request from the filesystem) and june build (frozen into the worker manifest) — so the routes you see in dev are the routes that deploy, by construction rather than by discipline. june info prints the resolved table at any moment.

This very site is the demo: /docs/[slug] and /blog/[slug] are dynamic segments, the docs sidebar is a nested layout, and _content.ts / _sections.ts sit colocated inside app/ without ever becoming routes.

Stated limits

loading.tsx and error.tsx are recognized by the matcher (each segment's special files travel with the match) but not yet wired: loading needs streamed Suspense, segment error boundaries are a later milestone — see RSC for the same honesty about streaming.

Why it matters

A route table you write by hand is a route table that drifts. Conventions a human can't misread are also conventions a coding agent can't misread — the filesystem is the one source both audiences already know how to navigate.