Skip to content

Routing and pages

Routes are files under routes/. A .stator file is a page; a .ts file is an API route.

routes/index.stator → /, routes/cart.stator → /cart. Discovery is automatic.

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.

Access the request in frontmatter via Stator.request:

---
const { params, query } = Stator.request
---

query collapses repeated keys to the first value.

Stator.reads([...]) hands the page live machine instances:

---
import CartMachine from '../machines/cart.ts'
const [cart] = Stator.reads([CartMachine])
---

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>

Set status, headers, or cookies during render via Stator.response:

---
const res = Stator.response
if (!user) res.status = 401
---

Add a pragma to opt the route into SSE:

---
// @stator live
---

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 404
Stator.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.