Routing and pages
Routes are files under routes/. A .stator file is a page; a .ts file is an API route.
File-based pages
Section titled “File-based pages”routes/index.stator → /, routes/cart.stator → /cart. Discovery is automatic.
Path params
Section titled “Path params”A [name] segment captures into request.params (always strings):
routes/product/[id].stator → /product/p1 → params.id === "p1"More specific routes win over param routes (/product/new beats /product/[id]).
A [...name] segment is a catch-all: it must be the last segment, matches zero or more segments, and captures the raw remainder — slashes included:
routes/media/[...path].ts → /media/2026/08/x.jpg → params.path === "2026/08/x.jpg" → /media → params.path === ""Specificity per segment runs static > extension-suffixed param ([id].json) > param > catch-all, so /media/pinned.ts and /media/[id].ts both win before the catch-all sees anything. A catch-all can’t carry an extension suffix ([...path].jpg is a discovery error) — parse the extension from the captured remainder instead, and return a Response with an explicit Content-Type.
Query and request
Section titled “Query and request”Access the request in frontmatter via Stator.request:
---const { params, query } = Stator.request---query collapses repeated keys to the first value.
Declare machine reads
Section titled “Declare machine reads”Stator.reads([...]) hands the page live machine instances:
---import CartMachine from '../machines/cart.ts'const [cart] = Stator.reads([CartMachine])---Layouts via composition
Section titled “Layouts via composition”Wrap pages in a layout component that exposes insertion points with <children>:
<!-- customer-layout.stator --><header><children name="header" /></header><main><children /></main><CustomerLayout><CartPage cart={cart} /></CustomerLayout>Response side effects
Section titled “Response side effects”Set status, headers, or cookies during render via Stator.response:
---const res = Stator.responseif (!user) res.status = 401---Live updates
Section titled “Live updates”Add a pragma to opt the route into SSE:
---// @stator live---Missing things: the when() 404 idiom
Section titled “Missing things: the when() 404 idiom”Dynamic routes validate their params in the frontmatter and branch — there is no first-class 404 API:
---const found = catalog.bySlug(String(params.slug))---{when(!found, () => ( <section> <h1>Never stocked that.</h1> <p><a href="/">Back to the shop.</a></p> </section>))}{when(!!found, () => ( /* the real page */))}Set a real status code where it matters (crawlers): Stator.response.status = 404 in the frontmatter’s not-found path.
Not found: the cascade and routes/404.stator
Section titled “Not found: the cascade and routes/404.stator”A route’s own 404 doesn’t end the request. When a GET route answers 404 — Stator.response.status = 404 on a page, a raw Response with status 404 from a data route — the request cascades to the next-most-specific route that also matches, in the same priority order discovery already sorts by. Only when every matching candidate has 404’d does the response go out, using the last candidate’s own body and headers. A stale [slug] can therefore fall through to a [...rest] that serves the path.
routes/404.stator (or routes/404.ts) is the last resort: sugar for a [...name] catch-all at that position, forced below even a real catch-all you write yourself at the same depth. It renders once nothing else could serve the request, so a not-found page needs no bracket-rest trick and no branching inside the route that missed:
---// routes/404.stator — reached only after every matching route returned 404Stator.response.status = 404---<h1>Never stocked that.</h1><p><a href="/">Back to the shop.</a></p>The when() idiom above still has its place when a page wants to render its own not-found state in its own layout.