Typed Client SDK
Call backend actions through a typed HTTP client with automatically generated browser contracts.
The Brick-TS client SDK uses a generated browser contract to infer action inputs and outputs and preserve exact HTTP routes. Use TanStack Query options for query hooks, mutations, and typed cache keys.
Minimal Example
The client is typed by a generated contract: browser-safe route metadata and transport types derived from your app. Your app entry must default-export a BrickApp created by brick() (without calling .listen()).
Generate the contract with the Vite plugin, which writes _brick/contract.ts on dev startup and build and regenerates it when the app changes:
import { defineConfig } from "vite";
import { brickClient } from "@brickkit/cli/vite";
export default defineConfig({
plugins: [brickClient({ entry: "./src/api/app.ts" })],
});Or generate it without Vite:
brick gen client src/api/app.ts
# default output: _brick/contract.ts
brick gen client src/api/app.ts --output frontend/src/contract.tsThen import createBrickClient from @brickkit/core/client (a browser-safe subpath) and pass the contract:
import { createBrickClient } from "@brickkit/core/client";
import { contract } from "../../_brick/contract";
export const api = createBrickClient({
baseUrl: "http://localhost:4000",
contract,
});
// CRUD resources live at the root: api.<resource>.<operation>
const response = await api.page.list({
where: { field: "title", op: "contains", value: "Launch" },
limit: 5,
});
console.log(response.items); // Typed from the resource's read fieldsCustom actions are grouped by service name (api.<service>.<action>), and dotted action names become nested namespaces. The contract carries each action's HTTP method and path, including CRUD routes and custom path/method overrides. GET and DELETE input is sent as a query string; other methods send JSON.
Client Configuration (createBrickClient)
The client constructor accepts the following options:
| Option | Type | Default | Description |
|---|---|---|---|
baseUrl | string | (Required) | Server origin (e.g. "http://localhost:4000"). Trailing slashes are trimmed. |
contract | Record<string, any> | undefined | Generated contract. Without it, every call is POST {prefix}/<service>/<action>, so CRUD and custom-path routes won't resolve. |
prefix | string | "/api" | Route prefix used only for calls without contract metadata. Must match brick({ prefix }). |
headers | Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>) | undefined | Static headers, or a function re-evaluated on every call. |
fetch | (input, init) => Promise<Response> | globalThis.fetch | Custom fetch implementation for tests, proxies, or credentials: "include". |
timeoutMs | number | undefined | Default per-call timeout in milliseconds (uses AbortSignal.timeout). |
signal | AbortSignal | undefined | Default abort signal for every call. |
Dynamic Authentication Headers
Pass an async function to headers to attach dynamic authentication tokens to every outgoing request:
export const api = createBrickClient({
baseUrl: "https://api.example.com",
contract,
headers: async (): Promise<Record<string, string>> => {
const token = await getSessionToken();
return token ? { Authorization: `Bearer ${token}` } : {};
},
});Per-Call Options
Every client invocation accepts an optional second argument for request-specific headers, an abort signal, or a timeout. Per-call headers merge over the client defaults:
const controller = new AbortController();
const pages = await api.page.list(
{ limit: 10 },
{
signal: controller.signal,
headers: { "X-Custom-Trace": "req_123" },
},
);A per-call signal replaces the client's default signal. The timeout remains active alongside that signal, using per-call timeoutMs when supplied and client timeoutMs otherwise. Signal cancellation rejects with the native AbortError. A timeout aborts with TimeoutError, which the client reports as BrickTransportError.
Error Handling
When a request fails, the client rejects according to the response:
- A body with a string
code(declared errors, standard helpers, authorization failures) →ActionExecutionErrorwithcode,status,message, anddetails. - A body with
name: "ValidationError"→ValidationErrorwitherrors. - Any other 5xx →
BrickServerError; any other non-OK response, a network failure, or an unparsable body →BrickTransportError.
@brickkit/core/client exports the client builder, the transport classes and
guards, and the type utilities — but not ActionExecutionError or
ValidationError, which live in the server entry @brickkit/core. In browser
code, narrow them by err.name instead:
import {
type InferActionFailure,
isBrickServerError,
isBrickTransportError,
} from "@brickkit/core/client";
import { api } from "./api";
try {
const user = await api.users.getUser({ id: "usr_99" });
} catch (err) {
if (err instanceof Error && err.name === "ActionExecutionError") {
// Domain error from the action (declared code or e.g. NOT_FOUND, FORBIDDEN)
const failure = err as InferActionFailure<unknown>;
console.error(`[${failure.code}] (${failure.status}): ${failure.message}`);
console.error("Details:", failure.details);
} else if (err instanceof Error && err.name === "ValidationError") {
// TypeBox schema validation failure
console.error("Validation error:", (err as any).errors);
} else if (isBrickServerError(err)) {
// 5xx without a Brick error shape
console.error("Server failure:", err.status, err.message);
} else if (isBrickTransportError(err)) {
// Network failure, unparsable body, or unrecognized 4xx
console.error("Transport failure:", err.status, err.message);
}
}Type Utilities
Extract static types directly from actions or client functions:
import type {
InferActionErrorCodes,
InferActionInput,
InferActionOutput,
} from "@brickkit/core/client";
import type { getUser } from "../server/users";
export type GetUserInput = InferActionInput<typeof getUser>;
export type GetUserOutput = InferActionOutput<typeof getUser>;
export type GetUserErrorCodes = InferActionErrorCodes<typeof getUser>;Generate the contract automatically
For Vite 8 apps, use the brickClient plugin shown above. It generates the contract before frontend startup and production compilation. For type checks on a clean checkout, initialize and close a Vite server before running tsc, as demonstrated in examples/pages/scripts/typecheck.ts.
The plugin tracks the app entry and imported dependencies. Schema changes, HTTP method and path edits, and action additions or removals regenerate the contract. Rapid changes coalesce. The app definition graph, including imported service instances, is invalidated before evaluation so removed actions cannot persist in cached service objects.
Changed contracts are replaced atomically. Implementation-only edits leave an unchanged contract's content and timestamp intact, so they do not trigger frontend contract updates. The generated output is excluded from regeneration triggers. If an edit breaks generation, Vite reports it in the terminal and browser overlay, preserves the previous contract, and retries after a valid edit.
For separate backend and frontend processes, use the CLI's coordinated development workflow:
brick dev src/api/app.ts --client frontend/src/contract.ts --frontend 'bun run --cwd frontend dev'For a non-Vite frontend, put generation first in build and typecheck scripts:
{
"scripts": {
"build": "brick gen client src/api/app.ts --output frontend/src/contract.ts && bun run --cwd frontend build",
"typecheck": "brick gen client src/api/app.ts --output frontend/src/contract.ts && bun run --cwd frontend typecheck"
}
}All paths use the same contract generator and atomic writer. Generated browser modules contain transport types and route metadata, without backend implementations or database imports. Generation evaluates the app entry, so keep listeners, startup jobs, and other startup side effects in a separate bootstrap. Required configuration must be available during generation.