Routes are declared as a nested tree of plain objects and passed to
defineRoutes (from brustjs/routes), which flattens the tree into the table
the Rust router matches against. Structural mistakes — an index route with
children, a native route mixed into a React chain — throw at module load, before
the server binds.
The Route shape
| Field | Type | Description |
|---|---|---|
path |
string |
Relative path segment in matchit syntax. '' makes the node layout-only (contributes nothing to the URL). Mutually exclusive with index. |
index |
boolean |
Matches the parent path exactly. Must be a leaf — no children, no path. |
Component |
ComponentType |
The component rendered for this node. Required (and must be a named function) when native: true. |
loader |
async ({ params, path, req }) => Data |
Runs in the worker before the render; its return value becomes the component's data prop (or, for native routes, the template context). |
native |
boolean |
Compile this route's JSX to a template at build time and render it in Rust. See Rendering Modes. |
children |
Route[] |
Nested children; each child's path composes onto this node's path. |
middleware |
Middleware[] |
Per-route middleware chain, concatenated with ancestors (parent runs first). |
errorBoundary |
ComponentType<{ error: Error }> |
Invoked when Component or loader throws. Inherited by descendants that don't define their own (closest ancestor wins). |
cache |
{ ttl_seconds, vary? } |
Opt-in response cache. Read from the leaf only — a parent's cache is ignored. Not allowed on native routes (deferred). |
sse |
(req) => ReadableStream |
Stream a text/event-stream response. Cannot coexist with Component, loader, or children. |
websocket |
() => Promise<WsHandlers> |
Accept WebSocket upgrades. Cannot coexist with Component, loader, sse, or children. |
The validation rules enforced at defineRoutes time:
indexandpathare mutually exclusive; an index route cannot have children.- Every node must have
path,index, orchildren. - An absolute child path (starting with
/) is only allowed under a pathless ('') parent — otherwise the child would escape its parent's URL space. native: truerequires a namedComponent, rejectssse/websocket/cache, and — if it haschildren— the entire subtree must also be native. A native leaf reached through a non-native ancestor is equally rejected: a native chain is composed into one template at build time, and a React node anywhere in it would break that.
Params and wildcards
Path segments use matchit syntax. {name} matches exactly one segment and
lands in params.name as a string; {*name} is a catch-all that captures the
rest of the path.
{ path: '/blog/{slug}', Component: Post, loader: async ({ params }) => getPost(params.slug) }
// /blog/hello → params.slug === 'hello'
{ path: '/files/{*rest}', Component: FileBrowser }
// /files/a/b/c → params.rest === 'a/b/c'
Nesting, layouts, and <Outlet/>
A parent with children becomes a layout. On the React path the parent
renders the matched child wherever it places <Outlet/> (a React context
read; it renders nothing at a leaf):
import { defineRoutes, Outlet } from 'brustjs/routes'
function AdminLayout() {
return (
<div>
<nav>…</nav>
<main><Outlet /></main>
</div>
)
}
export const routes = defineRoutes([
{
path: '/admin',
Component: AdminLayout,
children: [
{ index: true, Component: Dashboard },
{ path: 'users/{id}', Component: UserDetail },
],
},
])
Along a matched chain:
- Loaders run top-down (parent → leaf). For native chains the results are
shallow-merged into one flat context — child keys win over parent keys — and
the first
notFound()/redirect()verdict short-circuits the rest. - Middleware concatenates root → leaf; the parent's middleware runs first.
errorBoundaryis inherited from the closest ancestor when the leaf has none.cachecomes from the leaf only.
Native routes support the same nesting: a native: true parent with native
children compiles the whole chain into a single template per leaf at build
time (the layout's <Outlet/> becomes the splice point). The constraint is
that the chain must be native end to end.
Index routes
{ index: true } matches the parent's path exactly — the conventional way to
give a layout a landing page, as in the /admin example above. An index route
is always a leaf.
Middleware
Middleware is an Express/Koa-style chain over the structured request:
import type { Middleware } from 'brustjs/routes'
const requireAuth: Middleware = async (req, next) => {
if (!req.cookies.session) {
return { status: 302, body: '', headers: { Location: '/login' } }
}
const res = await next() // runs the rest of the chain (loader + render)
res.headers = { ...res.headers, 'x-frame-options': 'DENY' }
return res
}
Return a RouteResponse ({ status, body, headers?, contentType? }) without
calling next() to short-circuit, or call await next() and mutate the
result. Two caveats:
- The response cache lookup happens before any middleware runs.
- On native routes, mutations made after
next()(status, headers) are not forwarded — the Rust render path emits its own 200. Short-circuiting works everywhere. If your middleware must mutate the rendered response, use a React route.
Cookies
Reading incoming cookies is structural — req.cookies on the request, as in
the middleware above. For writing, brustjs exports a request-scoped
helper usable anywhere inside a request (loader, middleware, action) without
threading the request through:
import { cookies } from 'brustjs'
loader: async ({ req }) => {
const seen = cookies.get('seen') // same data as req.cookies.seen
cookies.set('seen', '1', { maxAge: 86_400, httpOnly: true, sameSite: 'Lax' })
// cookies.delete('legacy') // Max-Age=0 under the hood
return { firstVisit: !seen }
}
set/delete stage a Set-Cookie header that is flushed onto the response.
Options: maxAge, expires, path, domain, secure, httpOnly,
sameSite: 'Strict' | 'Lax' | 'None'. Values are URL-encoded; names are
validated as RFC 6265 tokens (header-injection guarded). serializeCookie
(same module) is the underlying one-liner if you build headers by hand.
One caveat: on native routes the Rust render path owns the response, so
staged cookies.set() calls are dropped — reading works everywhere, but set
cookies from an action or a React route. Outside a request
scope, set/delete are no-ops (warned in dev).
Request-scoped loader helpers
Loaders across a nested chain (or one loader called several times while
rendering) often want the same data. brustjs exports two per-request
memoizers — both scoped to the current request, both pass-through outside
one:
import { dedupe, cachedFetch } from 'brustjs'
// Share one in-flight promise + result for the request's lifetime:
const user = await dedupe('user:42', () => db.users.find(42))
// fetch() deduped per request for idempotent calls (GET/HEAD):
const res = await cachedFetch('https://api.example.com/pokemon/ditto')
cachedFetch keys on method + URL only (not headers/body) and returns a
fresh Response clone per call; non-idempotent methods bypass the cache.
Note the scope is a single request — for caching across requests, use the
response cache or ISR islands.
notFound and redirect
A native route loader controls the HTTP response by returning a verdict
instead of data — both helpers come from brustjs/routes:
import { notFound, redirect } from 'brustjs/routes'
loader: async ({ params }) => {
const post = await getPost(params.slug)
if (!post) return notFound({ attempted: params.slug }) // 404, renders this route's own template
if (post.movedTo) return redirect(`/blog/${post.movedTo}`, 301)
return { post }
}
notFound(data?) renders the route's own template with HTTP 404 and data
(default {}) as the context. redirect(location, status?) emits a
Location header with no render; the status defaults to 302 and accepts
301 | 302 | 303 | 307 | 308. In a nested chain, the first verdict
encountered top-down wins and the remaining loaders are skipped.
Next
How each route turns into HTML: Rendering Modes.