A brust app has two required files: an entry that starts the server, and a
route table. Everything on this page works in a fresh
scaffolded project or an empty directory with brustjs
installed.
The entry
// index.ts
import { brust } from 'brustjs'
import { routes } from './routes'
await brust.run({ routes, entry: import.meta.url })
A route
Routes are plain objects passed to defineRoutes. The smallest useful route
has a path and a Component:
// routes.tsx
import { defineRoutes } from 'brustjs/routes'
function Hello() {
return <h1>Hello from brust</h1>
}
export const routes = defineRoutes([
{ path: '/', Component: Hello },
])
Run it:
brustjs dev # or `bun run dev` in a scaffolded project
brust dev compiles native templates, starts the server with hot reload, and
serves on port 1337 by default (override with --port, BRUST_PORT, or
brust.toml — see Project Structure).
Adding a loader
A loader is an async function that runs in the worker before the render.
It receives a context with three fields:
| Field | Type | Description |
|---|---|---|
params |
Record<string, string> |
Path parameters extracted by the router |
path |
string |
The matched request path |
req |
BrustRequest |
Structured request: method, url, headers, cookies, search, signal |
The loader's return value becomes the component's data prop. The component
also receives params, path, req, and workerId:
import { defineRoutes, type RouteContext } from 'brustjs/routes'
function BlogPost({ params, data }: RouteContext<{ slug: string }, { title: string }>) {
return <h1>{data.title}</h1>
}
export const routes = defineRoutes([
{
path: '/blog/{slug}',
Component: BlogPost,
loader: async ({ params }) => ({ title: `Post: ${params.slug}` }),
},
])
Path parameters
Paths use matchit syntax: a segment written {name} matches one segment and
lands in params.name as a string.
/blog/{slug} → /blog/hello-world → params.slug === 'hello-world'
/admin/users/{id} → /admin/users/42 → params.id === '42'
The native variant
Add native: true and the same route stops rendering with React on the
server: at build time the component is compiled to a template, and at request
time Rust renders it with the loader's return value as the template context.
export const routes = defineRoutes([
{
path: '/blog/{slug}',
Component: BlogPost,
native: true,
loader: async ({ params }) => ({ title: `Post: ${params.slug}` }),
},
])
Two rules to know up front:
Componentis required and must be a named function — the build keys the compiled template on the function's name.- Anything the template displays should be precomputed in the loader. Native templates bind data; they do not run arbitrary JavaScript at render time.
Native loaders can also short-circuit the render by returning
notFound(data?) (renders the route's own template with HTTP 404) or
redirect(location, status?) — both imported from brustjs/routes.
Next
See how a project fits together: Project Structure, or go deeper on the route tree in Routing.