brick.ts

Typed Client SDK

Call backend actions through a typed HTTP client with automatically generated browser contracts.

llms.txt

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:

vite.config.ts
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.ts

Then import createBrickClient from @brickkit/core/client (a browser-safe subpath) and pass the contract:

src/lib/api.ts
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 fields

Custom 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:

OptionTypeDefaultDescription
baseUrlstring(Required)Server origin (e.g. "http://localhost:4000"). Trailing slashes are trimmed.
contractRecord<string, any>undefinedGenerated contract. Without it, every call is POST {prefix}/<service>/<action>, so CRUD and custom-path routes won't resolve.
prefixstring"/api"Route prefix used only for calls without contract metadata. Must match brick({ prefix }).
headersRecord<string, string> | (() => Record<string, string> | Promise<Record<string, string>>)undefinedStatic headers, or a function re-evaluated on every call.
fetch(input, init) => Promise<Response>globalThis.fetchCustom fetch implementation for tests, proxies, or credentials: "include".
timeoutMsnumberundefinedDefault per-call timeout in milliseconds (uses AbortSignal.timeout).
signalAbortSignalundefinedDefault abort signal for every call.

Dynamic Authentication Headers

Pass an async function to headers to attach dynamic authentication tokens to every outgoing request:

src/lib/api.ts
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:

src/components/PageList.tsx
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) → ActionExecutionError with code, status, message, and details.
  • A body with name: "ValidationError" → ValidationError with errors.
  • 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:

src/lib/fetch-user.ts
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:

src/types/client.ts
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:

package.json
{
  "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.