brust 2.0.0-alpha.5

React you write.
Rust that serves it.

staticnativereactREACT IN · HTML OUT
routes.tsx1:edit
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 },
  ] },
])
~/0:install
$ bun create @brust/app@latest my-app
$ cd my-app && bun install
~/my-app2:run
$ 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
[my-app]0:install1:edit2:runbrust 2.0.0-alpha.5

What happens to one request

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
  1. Rust

    Match the route

    /demo/pokemon/{name}, with name = pikachu.

  2. Rust

    Check the cache

    Nothing stored yet for this URL.

  3. Bun

    Run the loaders

    Layout first, then the page. Their results become the page's props.

  4. Rust

    Render the page

    From templates compiled at build time, then stored for 60 seconds.

  5. Browser

    Gets HTML

    x-brust-cache: MISS. No script tag: nothing on this page is interactive.

Measured, not promised

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.

113,744req/s

on a cached page, across 2,324,676 requests in the three runs, every one answered 200 OK

Cached page

L1 hit

GET /types

113,744
requests / second
p50
1.0ms
p99
1.3ms
throughput
194MB/s
0.16 mslatency4.1 ms

Interactive page

one React island

GET /team

104,889
requests / second
p50
1.1ms
p99
2.1ms
throughput
82MB/s
0.09 mslatency10.9 ms

Dynamic page

loader every request

GET /dex

13,759
requests / second
p50
8.3ms
p99
20.1ms
throughput
299MB/s
0.51 mslatency70.5 ms

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/.

Sixty seconds to a served page

No config to write first. The template already has a layout, a page and the scripts.

  1. 1

    Create

    One command scaffolds the project and installs its dependencies.

    ~/
    $ bun create @brust/app@latest my-app
    $ cd my-app && bun install
  2. 2

    Write

    A page is a React component. There is nothing to import from brust.

    pages/HomePage.tsx
    export default function HomePage() {
      return <h1>Hello from brust</h1>
    }
  3. 3

    Run

    Build compiles every component; start serves them from Rust.

    ~/my-app
    $ bun run dev
    [brust] built dist in 16ms
    [brust] listening on 127.0.0.1:1337 (io: hyper(tokio))
    [brust] ready (10 workers)

One component model, three outputs

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.

PokemonCard.tsxbrust buildstatic
components/PokemonCard.tsx
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>
  )
}
127.0.0.1:1337/pokedexRendered
Bulbasaur#001

Bulbasaur

grasspoison
Charmander#004

Charmander

fire
Pikachu#025

Pikachu

electric
HTML
rendered on the server
JavaScript
none
Hydration
none

Data stays next to the route

Each route names its loader, and layouts nest through Outlet with their loader results merged top-down.

  1. 1Params arrive decoded, including encoded slashes.
  2. 2The loader runs in Bun with the request in hand and returns the page's props.
  3. 3Caching is one line. A hit is answered by Rust and never wakes Bun.
  4. 4Invalidate by tag from wherever the data changed.
routes.tsx
{
  path: '/pokemon/{name}',1
  Component: DetailPage,
  loader: detailLoader,2
  cache: { ttl_seconds: 60, tags: ['pokemon', 'team'] },3
}
actions.ts
import { cache } from '@brust/core'

// after the roster changes:
await cache.invalidate({ tags: ['team'] })4

Actions, typed end to end

One Elysia app, served by Rust under /_brust/action. The browser gets a client that knows every route and its types.

actions.ts
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 app
in the browser
const api = client<App>()
const { data } = await api.hello.get({ query: { name: 'ash' } })

One store, both sides

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.

stores/team.ts
import { defineStore, signal } from '@brust/core/store'

export const team = defineStore('team', () => ({
  members: signal([]),
  max: signal(6),
}))
components/TeamCount.tsx
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>
}

Deploy where you like

The same routes file builds for a Bun process on your own machine or for Cloudflare Workers. Nothing in the app changes.

Your server

A Rust server handles HTTP; Bun workers run loaders and actions beside it.

~/my-app
$ bun run build
$ brust start --port 8080 --workers 4

Cloudflare Workers

The build emits a Worker, the static assets and a merged wrangler.jsonc. D1 and KV bind as usual.

~/my-app
$ brust build routes.tsx --target cloudflare
$ wrangler deploy -c dist/cf/wrangler.jsonc

Packages

Install only what the app uses. Everything publishes in lockstep at the same version.

@brust/core

Routes, loaders, cache, actions, store, the CLI and the native server.

bun add @brust/core
@brust/create-app

The scaffolder behind bun create @brust/app.

bun create @brust/app@latest
@brust/core-cf

The same runtime as a Worker, used by the Cloudflare target.

installed with @brust/core
@brust/runtime-dom

The browser runtime for native components: signals and bindings.

installed with @brust/core
@brust/cache-redis

A shared cache tier on Redis or Dragonfly, with invalidation across instances.

bun add @brust/cache-redis
@brust/cache-cloudflare

The shared tier on KV, with a Durable Object for invalidation.

bun add @brust/cache-cloudflare

What brust is not

Fewer surprises if you know the shape before you start.

a single-page app

Navigation is server-first. Pages render on the server; islands hydrate only where they appear.

file-system routing

Routes live in one explicit routes.tsx, so you can read the whole site in one file.

a React replacement

Islands run real React 19. The compiler only steps in where React would be dead weight.

Read the quickstart, then ship something small.