Every route in a brust app renders one of two ways — as a native template in Rust, or as a streaming React render in a Bun worker — and either kind of page can embed islands. This page covers all three mechanisms and when to reach for each.
Native routes (native: true)
A route flagged native: true is compiled at build time: the JSX
component is lowered to a minijinja template, and at request time Rust renders
it directly. The loader still runs in the worker with the full middleware
chain and req; its return value is serialized and becomes the template
context. No React executes on the server for that page, and no JavaScript
ships to the browser by default.
// pages/Profile.tsx
export default function Profile({ user }: { user: string }) {
return <div><h1>Hello, {user}!</h1></div>
}
// routes.tsx
{
path: '/u/{name}',
Component: Profile,
native: true,
loader: async ({ params }) => ({ user: params.name }),
}
Because the output is a data-driven template, the JSX is constrained:
- Expressions are member paths —
{user},{data.post.title}. Computed values belong in the loader, not the template. - Lists are
.map()over a member path, nested to any depth. The two-arg(item, index)form is rejected. - Conditionals —
{cond && <X/>}and{cond ? <A/> : <B/>}— lower to{% if %}blocks. They work in child (element) position, not in attribute position. - Capitalized components inside a native page either render as SSR
component slots (a worker
renderToStringfills them per request) or, with thenativeattribute (<Badge native label={x} />), are inlined into the template at compile time — provided the component body is pure.
The exact edges are catalogued in
Native Interactivity, including patterns that do
not compile. A notFound()/redirect() verdict from the loader controls the
response — see Routing.
React streaming SSR
Routes without native: true render with full React on the server:
renderToPipeableStream in a Bun worker, streamed to the client with Suspense
support — the shell flushes first and suspended subtrees stream in as they
resolve. Use this when a page genuinely needs React on the server: heavy
composition, context providers, third-party React components, or <Suspense>
around slow data.
One tuning knob worth knowing: render slots. By default each worker holds
one render in flight; a Suspense- or loader-bound page that spends its time
awaiting I/O can overlap renders in the same worker by raising
tuning.renderSlots in brust.run's serve options (or the
BRUST_RENDER_SLOTS environment variable). CPU-bound pages don't benefit —
they serialize in the single JavaScript isolate either way.
Islands
An island is an interactive React component mounted explicitly inside a page
with <Island> (exported from brustjs). Only islands ship JavaScript; each
hydrates independently.
import { Island } from 'brustjs'
import Counter from './components/Counter'
<Island component={Counter} props={{ start: 5 }} hydrate="idle" />
The props of <Island> itself:
| Prop | Type | Default | Description |
|---|---|---|---|
component |
ComponentType<P> |
— | The island. Must be a stable named component — its name keys the client chunk and the hydration marker. |
props |
P |
{} |
Passed to the component on server and client. Must be JSON-serializable (no functions, DOM nodes, …). |
hydrate |
'load' | 'idle' | 'visible' | 'interaction' |
'load' |
When to hydrate: immediately, on requestIdleCallback, when scrolled into view, or on first pointer/keyboard interaction. |
ssr |
boolean |
false |
Native routes only: render the island to HTML server-side so its markup ships in the page, then hydrate. Ignored on React routes (the whole tree already SSRs). |
isr |
{ key, tags?, revalidate? } |
— | Cache the server render of an ssr island (below). |
ISR — caching server-rendered islands
An ssr island on a native route can opt into incremental static
regeneration: its server render runs once per key, and later requests
serve the frozen markup from a Rust-side cache shared across the worker pool.
<Island
component={ProductCard}
props={data.product}
ssr
isr={{ key: data.cacheKey, tags: ['product'], revalidate: 60 }}
/>
key(required) — unique string identifying the cache entry; a different key is a different cached render. Compute it in the loader.tags(optional) — groups for bulk invalidation.revalidate(optional) — TTL in seconds (integer). Omit to cache until explicitly invalidated.
Invalidate from an action or loader:
import { cache } from 'brustjs'
cache.invalidate({ tags: ['product'] }) // evict every entry carrying the tag
cache.invalidate({ key: 'p_42' }) // evict one entry
The cached HTML and props are stored and served together, so hydration always
sees matching markup. isr is meaningless without ssr (there is nothing
server-rendered to cache).
Which mode, when
| Situation | Reach for |
|---|---|
| Content page, data known in the loader | Native route |
| Mostly-static page with a few interactive widgets | Native route + islands |
| Interactive widget whose markup matters for first paint or SEO | ssr island (+ isr if the render is expensive) |
| Light interactivity — a toggle, a filter — without React | Native route + a behavior component |
| Page needing React context, Suspense streaming, or React-only libraries server-side | React streaming route |
The cheapest page is the one Rust renders alone; the framework is built so you can stay on that path by default and buy back React exactly where it pays.
Next
Make a native page interactive without React: Native Interactivity.