Cached page
L1 hitGET /types
- p50
- 1.0ms
- p99
- 1.3ms
- throughput
- 194MB/s
import { defineRoutes } from '@brust/core/routes' import Layout from './components/Layout' import HomePage from './pages/HomePage' export const routes = defineRoutes([ { Component: Layout, children: [ { path: '/', Component: HomePage }, ] }, ])
$ bun create @brust/app@latest my-app $ cd my-app && bun install
$ curl -i http://127.0.0.1:1337/ HTTP/1.1 200 OK x-powered-by: brust/2.0.0-alpha.5 content-type: text/html; charset=utf-8
Rust receives every request and writes every response. Bun runs your loaders, and is skipped when the cache already has the page.
GET /demo/pokemon/pikachu/demo/pokemon/{name}, with name = pikachu.
Nothing stored yet for this URL.
Layout first, then the page. Their results become the page's props.
From templates compiled at build time, then stored for 60 seconds.
x-brust-cache: MISS.x-brust-cache: HIT. No script tag: nothing on this page is interactive.
Tested with oha: 120 concurrent connections for 10 seconds per page, brust with 10 workers on a MacBook Pro M1 Pro. Three pages from the pokedex example, each on a freshly started server.
on a cached page, across 2,324,676 requests in the three runs, every one answered 200 OK
GET /types
GET /team
GET /dex
Bars show each page's latency distribution on a linear scale, fastest to slowest response. Measured 2026-10-11; the harness and every raw run are in the repo under bench/.
No config to write first. The template already has a layout, a page and the scripts.
One command scaffolds the project and installs its dependencies.
$ bun create @brust/app@latest my-app
$ cd my-app && bun installA page is a React component. There is nothing to import from brust.
export default function HomePage() {
return <h1>Hello from brust</h1>
}Build compiles every component; start serves them from Rust.
$ bun run dev
[brust] built dist in 16ms
[brust] listening on 127.0.0.1:1337 (io: hyper(tokio))
[brust] ready (10 workers)You write ordinary React. The compiler reads each component, picks the cheapest output that still behaves the same, and tells you what it picked. Left is the code, right is what the browser shows.
export default function PokemonCard({ no, name, types, art }: { no: string; name: string; types: string[]; art: string }) {
return (
<article className="grid min-w-0 content-start gap-[.3rem] rounded-xs border border-rule bg-paper p-[.8rem]">
<img className="aspect-square h-auto w-full max-w-full rounded-md bg-sheet p-[.35rem]" src={art} alt={name} width="200" height="200" />
<span className="font-mono text-[12px] text-ink-2">{no}</span>
<h4 className="m-0 font-display text-[1rem]">{name}</h4>
<div className="flex flex-wrap gap-[.3rem]">
{types.map((t) => (
<span key={t} style={{ '--ty': 'var(--ty-' + t + ')' }} className={t === 'electric' ? 'rounded-full bg-(--ty) px-2 py-[.05rem] font-mono text-[11px] text-ty-dark-fg' : 'rounded-full bg-(--ty) px-2 py-[.05rem] font-mono text-[11px] text-white'}>
{t}
</span>
))}
</div>
</article>
)
}import { useState } from 'react'
export default function CatchCard({ no, name, art, shinyArt }: { no: string; name: string; art: string; shinyArt: string }) {
const [caught, setCaught] = useState(0)
const [shiny, setShiny] = useState(false)
const [log, setLog] = useState<{ id: number; name: string; from: string; to: string }[]>([])
const [wiggle, setWiggle] = useState(false)
return (
<div className="grid min-h-60 [.doc_&]:min-h-0 flex-1 grid-cols-[minmax(0,1fr)_minmax(0,13rem)] items-stretch gap-4 p-5 max-narrow:grid-cols-1">
<article className="grid min-w-0 content-start gap-[.6rem] rounded-xs border border-rule bg-paper p-[.9rem]">
<div className="grid place-items-center rounded-lg bg-sheet p-[.4rem]">
<img className={wiggle ? 'aspect-square h-auto w-full max-w-[170px] motion-safe:[transition:opacity_.2s] motion-safe:animate-mirai-wiggle' : 'aspect-square h-auto w-full max-w-[170px] motion-safe:[transition:opacity_.2s]'} src={shiny ? shinyArt : art} alt={shiny ? 'Shiny ' + name : name} width="200" height="200" />
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[12px] text-ink-2">{no}</span>
<h4 className="m-0 mr-auto font-display text-[1.05rem]">{name}</h4>
<span className="rounded-full bg-ty-electric px-2 py-[.05rem] font-mono text-[11px] text-ty-dark-fg">electric</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<label className="inline-flex cursor-pointer items-center gap-2 text-[14.5px] select-none" htmlFor="cc-shiny">
<input
type="checkbox"
className="peer absolute size-px opacity-0"
id="cc-shiny"
checked={shiny}
onChange={() => {
setLog([{ id: Date.now(), name: 'shiny', from: String(shiny), to: String(!shiny) }, ...log].slice(0, 6))
setShiny(!shiny)
}}
/>
<span className="relative h-5 w-[2.2rem] flex-none rounded-full bg-rule peer-checked:bg-amber-fill peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-amber after:absolute after:top-[2px] after:left-[2px] after:size-[calc(1.25rem-4px)] after:rounded-full after:bg-sheet after:shadow-[0_1px_2px_rgba(0,0,0,.25)] after:content-[''] peer-checked:after:[transform:translateX(.95rem)] motion-safe:[transition:background_.2s,transform_.2s_cubic-bezier(.2,.7,.2,1)] motion-safe:after:[transition:background_.2s,transform_.2s_cubic-bezier(.2,.7,.2,1)]" aria-hidden="true" />
Shiny
</label>
<button
type="button"
className="cursor-pointer rounded-xs border border-amber-fill bg-amber-fill px-4 py-[.55rem] text-on-amber [font-family:inherit] [font-size:inherit] [line-height:inherit] font-semibold tabular-nums motion-safe:[transition:transform_.1s] motion-safe:active:[transform:scale(.97)]"
onClick={() => {
setLog([{ id: Date.now(), name: 'caught', from: String(caught), to: String(caught + 1) }, ...log].slice(0, 6))
setCaught(caught + 1)
setWiggle(true)
setTimeout(() => setWiggle(false), 450)
}}
>
Catch · <span className="inline-block min-w-[1ch]">{caught}</span>
</button>
</div>
</article>
<div className="flex min-w-0 flex-col overflow-hidden rounded-xs border border-term-rule bg-term font-mono text-[12.5px] text-term-fg">
<div className="flex justify-between gap-2 border-b border-term-rule px-[.7rem] py-[.45rem] text-[11.5px] text-term-dim">
<span className="text-amber">Signals</span>
<span>no re-render</span>
</div>
<ol className="m-0 grid list-none content-start gap-[.35rem] px-[.7rem] py-2 [.doc_&]:pl-[1.4rem]" aria-live="polite">
{log.length === 0 ? <li className="text-term-dim">Use the switch or the button.</li> : null}
{log.map((e) => (
<li key={e.id} className="flex justify-between gap-2 whitespace-nowrap motion-safe:animate-mirai-slidein">
<b className="font-normal text-sy-fn">{e.name}</b>
<span className="text-term-dim">
{e.from} → <em className="not-italic text-sy-str">{e.to}</em>
</span>
</li>
))}
</ol>
</div>
</div>
)
}import { useDeferredValue, useState } from 'react'
export default function PokemonSearch({ all }: { all: { id: number; no: string; label: string; name: string; sprite: string }[] }) {
const [q, setQ] = useState('')
// useDeferredValue keeps the input responsive while the list filters: React only (an island)
const deferred = useDeferredValue(q)
const term = deferred.trim().toLowerCase()
const hits = all.filter((p) => p.name.includes(term)).slice(0, 8)
return (
<div className="min-h-60 flex-1 p-5 [.doc_&]:min-h-0">
<label className="sr-only" htmlFor="demo-q">
Search Pokémon
</label>
<input id="demo-q" className="w-full rounded-xs border border-rule bg-paper px-3 py-[.55rem] text-ink [font:inherit]" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search Pokémon, e.g. char" autoComplete="off" />
<ul className="m-0 mt-[.8rem] grid list-none grid-cols-2 gap-x-[.9rem] gap-y-[.35rem] p-0 text-[14.5px] max-narrow:grid-cols-1 [.doc_&]:mt-0 [.doc_&]:pl-[1.2rem]">
{hits.map((p) => (
<li key={p.id} className="flex min-w-0 items-center gap-[.4rem]">
<img className="size-10 flex-none [image-rendering:pixelated]" src={p.sprite} alt="" width="40" height="40" />
<span className="font-mono text-[12px] text-ink-2">{p.no}</span>
{p.label}
</li>
))}
{hits.length === 0 ? <li className="col-span-full text-ink-2">No Pokémon match that name.</li> : null}
</ul>
</div>
)
}Each route names its loader, and layouts nest through Outlet with their loader results merged top-down.
{
path: '/pokemon/{name}',1
Component: DetailPage,
loader: detailLoader,2
cache: { ttl_seconds: 60, tags: ['pokemon', 'team'] },3
}import { cache } from '@brust/core'
// after the roster changes:
await cache.invalidate({ tags: ['team'] })4One Elysia app, served by Rust under /_brust/action. The browser gets a client that knows every route and its types.
import { brust } from '@brust/core/elysia'
import { Elysia, t } from 'elysia'
const app = new Elysia({ adapter: brust() })
.get('/hello', { query: t.Object({ name: t.String() }) },
({ query }) => ({ message: `Hello, ${query.name}!` }))
export default app
export type App = typeof appconst api = client<App>()
const { data } = await api.hello.get({ query: { name: 'ash' } })Define a store once. The server fills it while rendering, the browser picks it up as a snapshot, and native components and islands read the same signals.
import { defineStore, signal } from '@brust/core/store'
export const team = defineStore('team', () => ({
members: signal([]),
max: signal(6),
}))import { useStore } from '@brust/core/store/react'
import { team } from '../stores/team'
export default function TeamCount() {
const s = useStore(team)
return <span>{s.members.length} / {s.max}</span>
}The same routes file builds for a Bun process on your own machine or for Cloudflare Workers. Nothing in the app changes.
A Rust server handles HTTP; Bun workers run loaders and actions beside it.
$ bun run build
$ brust start --port 8080 --workers 4The build emits a Worker, the static assets and a merged wrangler.jsonc. D1 and KV bind as usual.
$ brust build routes.tsx --target cloudflare
$ wrangler deploy -c dist/cf/wrangler.jsoncInstall only what the app uses. Everything publishes in lockstep at the same version.
@brust/coreRoutes, loaders, cache, actions, store, the CLI and the native server.
bun add @brust/core@brust/create-appThe scaffolder behind bun create @brust/app.
@brust/core-cfThe same runtime as a Worker, used by the Cloudflare target.
installed with @brust/core@brust/runtime-domThe browser runtime for native components: signals and bindings.
installed with @brust/core@brust/cache-redisA shared cache tier on Redis or Dragonfly, with invalidation across instances.
bun add @brust/cache-redis@brust/cache-cloudflareThe shared tier on KV, with a Durable Object for invalidation.
bun add @brust/cache-cloudflareFewer surprises if you know the shape before you start.
Navigation is server-first. Pages render on the server; islands hydrate only where they appear.
Routes live in one explicit routes.tsx, so you can read the whole site in one file.
Islands run real React 19. The compiler only steps in where React would be dead weight.