Skip to content
brust

Actions are the server endpoints of a brust app: a fluent defineActions builder declares them, the Rust router dispatches them, and a typed proxy client (the treaty client) calls them from the browser with end-to-end types — input, output, and a discriminated error union all inferred from the builder.

Defining actions

// actions.ts
import { defineActions, ActionError } from 'brustjs'
import { z } from 'zod'

export const actions = defineActions()
  .get('/notes', async ({ query }) => listNotes(query), {
    query: z.object({ tag: z.string().optional() }),
  })
  .post('/notes', async ({ body }) => createNote(body), {
    body: z.object({ title: z.string().min(1) }),
  })
  .delete('/notes/{id}', async ({ params, respond }) => {
    const ok = await deleteNote(params.id)
    if (!ok) throw new ActionError(404, 'NOT_FOUND', { data: { id: params.id } })
    return respond(null, { status: 204 })
  })

export type Actions = typeof actions

Register the builder with the server: brust.run({ routes, actions, entry: import.meta.url }).

The builder has one method per HTTP verb — get, post, put, patch, delete, head — each taking (path, handler, opts?), plus use(middleware) which applies to every endpoint registered after it. Paths must start with /; {id} and {*rest} segments become typed params. Duplicate METHOD path pairs throw at build time.

Per-endpoint options:

Option Type Description
body Standard Schema Validates the decoded body; the handler's body is the schema's output type. Failure → HTTP 422 with { error: { message, issues } }.
query Standard Schema Same for the query string.
middleware Middleware[] Appended after any use() middleware for this endpoint.
description string Build-time description, surfaced to the MCP tool manifest.
errors Record<code, Schema> Type-only: declares the domain errors the endpoint can throw, typing the treaty client's error union. The runtime ignores it.

Any schema implementing the Standard Schema interface works — Zod, Valibot, ArkType.

The handler context

Field Description
req The structured BrustRequest (method, url, headers, cookies, search, signal).
body The validated body (JSON, urlencoded, and multipart are all decoded).
params Path params, typed from {x} segments in the path literal.
query The validated query object.
headers Request headers, lower-cased.
respond Escape hatch for status/headers (below).

A handler's plain return value is JSON-encoded as a 200. To control the status or headers, return respond(body, { status?, headers? }). To signal a domain error, throw an ActionError:

throw new ActionError(409, 'ALREADY_EXISTS', {
  message: 'a note with this title exists', // optional; defaults to the code
  data: { title },                          // optional payload
})

It maps to a non-2xx response with the flat body { code, message, data? }. An unexpected throw becomes a 500 with { error: { message, name } }.

The treaty client

client from brustjs/client builds a proxy whose property path mirrors the endpoint path; the terminal method performs the fetch:

import { client } from 'brustjs/client'
import type { Actions } from '../actions'

const api = client<Actions>()

const { data, error } = await api.notes.get({ query: { tag: 'work' } })
//      ^ typed as the handler's return type        (static paths are fully typed)

await api.notes.post({ title: 'hello' })       // body is type-checked

// param segments are filled by a call with an object (positional, in order):
await api.notes({ id: '42' }).delete({})

Every call resolves (never throws) to a TreatyResponse:

Field Type Description
data Output | null The parsed body on 2xx, else null.
error { status, value } | null On non-2xx: value is the parsed body — for a thrown ActionError, the typed { code, message, data } union from the endpoint's errors option. status: 0 means the fetch itself failed.
status number HTTP status (0 on network failure).
headers Record<string, string> Response headers.
response Response | null The raw fetch Response.

So error handling is a narrow, not a try/catch:

const res = await api.notes({ id }).delete({})
if (res.error) {
  if (res.error.value.code === 'NOT_FOUND') { /* typed via opts.errors */ }
  return
}
res.data // typed, non-null here

GET and HEAD take only an options object ({ query?, headers? }); the other verbs take (body?, options?). A body containing a Blob/File (or a FormData instance) is sent as multipart automatically; everything else is JSON. Client options: client({ prefix?, headers?, fetch? })headers may be a function evaluated per request (handy for auth tokens).

Paths with {param} segments fall back to a permissive (untyped) proxy for the parameterized part; static paths get full inference.

The action prefix

Actions mount under /_brust/action by default — POST /notes is really POST /_brust/action/notes. Override it with brust.run({ actionPrefix: '/api' }). On React-rendered pages a non-default prefix is injected into the HTML as a global the client picks up automatically; native (template) pages don't bake it, so a treaty client used on a native page with a custom prefix should pass it explicitly: client({ prefix: '/api' }).

Actions are MCP tools

Every endpoint doubles as a Model Context Protocol tool: brust mounts an MCP server at POST /_brust/mcp and generates the tool manifest from the same builder — schemas, params, and the description option included. Tool calls dispatch through the same validation, middleware, and error contract as HTTP. See the Reference section for the agent surface.

Next

Stylesheets, Tailwind, and CSS Modules: Styling.