brick.ts
All posts
Building with BrickKit

Full-stack with the BrickKit typed client

Connect a Vite and React frontend to a BrickKit backend with a generated contract, TanStack Query hooks, and end-to-end compile-time types.

By BrickKit team

This guide builds a full-stack support ticket interface with Vite, React, TanStack Query v5, and BrickKit. You will generate a browser-safe API contract from your backend, call queries and mutations through typed options, render validation and not-found errors, and then rename a backend field to see the frontend fail to compile.

You need Bun and some familiarity with TypeScript and React. If you followed Build a REST API with BrickKit, you can reuse that backend. Otherwise, paste the starting point below.

Starting point

A BrickKit app exposes its actions and resources through a root BrickApp instance. In a full-stack project, the entry file must default-export this instance without calling .listen(), so that development tools and code generators can evaluate the service definitions safely.

Create src/app.ts:

import {
  brick,
  defineDatabase,
  defineService,
  sqliteTable,
  syncSchema,
  text,
} from "@brickkit/core";
import { crud } from "@brickkit/crud";

export const tickets = sqliteTable("tickets", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => crypto.randomUUID()),
  title: text("title").notNull(),
  status: text("status").notNull().default("open"),
  assignee: text("assignee"),
});

const database = defineDatabase({ tables: { tickets } });
export const supportService = defineService("support", { database });

supportService.resource({ name: "ticket", table: tickets, id: tickets.id }).use(
  crud({
    access: "public",
    fields: {
      id: { read: true, filter: ["eq", "in"] },
      title: {
        read: true,
        create: true,
        update: true,
        filter: ["eq", "contains"],
        sort: true,
      },
      status: {
        read: true,
        create: true,
        update: true,
        filter: ["eq"],
        sort: true,
      },
      assignee: {
        read: true,
        create: true,
        update: true,
        filter: ["eq"],
        sort: true,
      },
    },
  }),
);

syncSchema(supportService);

export const app = brick({
  services: [supportService],
});

export default app;

The service defines an in-memory SQLite table named tickets and registers CRUD operations under the ticket resource. The database lives in the dev process, so every reload starts with an empty table. When mounted, it publishes routes at /api/ticket and /api/ticket/query.

Set up the project

Create a project directory and install the backend and frontend packages:

bun init -y
bun add @brickkit/core @brickkit/crud @brickkit/cli
bun add @tanstack/react-query react react-dom
bun add -d @vitejs/plugin-react typescript vite @types/react @types/react-dom

Give package.json scripts for development, building, and type checking:

{
  "name": "support-tickets-app",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "bun --bun vite",
    "build": "bun --bun vite build",
    "typecheck": "tsc --noEmit"
  }
}

Create tsconfig.json so that TypeScript resolves modern modules and includes the generated contract files:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "isolatedModules": true
  },
  "include": ["src", "_brick"]
}

Generate the browser contract

Deriving client types does not need a running server or an OpenAPI download. The CLI evaluates the application's service graph and emits a TypeScript module with the transport types and endpoint metadata.

Generate the contract by hand once:

bunx brick gen client src/app.ts

Output:

Client contract written to .../_brick/contract.ts

Open the generated file, _brick/contract.ts (shown abridged here: the real file also holds the where helper types, an include option on list and get, and the findOne, count, and exists actions):

// Generated by the Brick Vite plugin. Do not edit.
import type { BrickClientAction } from "@brickkit/core/client";

export type BrickSchema_21f11d5e50df = {
  id: string;
  title: string;
  status: string;
  assignee: string | null;
};

export type AppContract = {
  support: {};
  ticket: {
    list: BrickClientAction<
      {
        select?: Array<"id" | "title" | "status" | "assignee">;
        where?: BrickCrudWhere_ticket;
        orderBy?: Array<{
          field: "title" | "status" | "assignee";
          direction: "asc" | "desc";
        }>;
        limit?: number;
        offset?: number;
        includeTotal?: boolean;
      },
      {
        items: Array<BrickSchema_21f11d5e50df>;
        total?: number;
        limit: number;
        offset: number;
      },
      {}
    >;
    get: BrickClientAction<
      { id: string; select?: Array<"id" | "title" | "status" | "assignee"> },
      BrickSchema_21f11d5e50df,
      {}
    >;
    create: BrickClientAction<
      { title: string; status?: string; assignee?: string | null },
      BrickSchema_21f11d5e50df,
      {}
    >;
    update: BrickClientAction<
      {
        id: string;
        data: { title?: string; status?: string; assignee?: string | null };
      },
      BrickSchema_21f11d5e50df,
      {}
    >;
    delete: BrickClientAction<{ id: string }, { success: true }, {}>;
  };
};

export const contract: AppContract = JSON.parse("...");

The contract keeps the transport metadata and leaves out the backend implementation:

  • Action signatures encode the exact parameters. title is a required string in ticket.create, while status and assignee are optional.
  • The runtime contract object holds the HTTP method and URL path for each operation (POST /api/ticket, PATCH /api/ticket/:id, and POST /api/ticket/query).
  • No database drivers, Drizzle ORM queries, or server dependencies enter this file, so none of them reach the browser bundle.

Configure the Vite plugin and dev server

Regenerating by hand after every schema change is easy to forget. The @brickkit/cli/vite plugin does it for you by hooking into Vite's module graph.

Create vite.config.ts:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { brickClient } from "@brickkit/cli/vite";

export default defineConfig({
  plugins: [
    brickClient({
      entry: "./src/app.ts",
      dashboard: false,
    }),
    react(),
  ],
  server: {
    proxy: {
      "/api": "http://localhost:4000",
    },
  },
});

The brickClient plugin runs when Vite starts or builds. It loads src/app.ts inside Vite's server module runner, extracts the application contract, and writes _brick/contract.ts.

When you edit a backend service or schema, the plugin detects the change, re-evaluates the module graph, and atomically overwrites _brick/contract.ts. The proxy configuration sends /api calls from the browser on the Vite port to the backend process on port 4000.

Create the TanStack Query client

createBrickQueryClient, from @brickkit/core/tanstack-query, combines the typed HTTP client with TanStack Query v5 options builders.

Create src/lib/api.ts:

import { createBrickQueryClient } from "@brickkit/core/tanstack-query";
import { contract } from "../../_brick/contract";

function resolveBaseUrl(): string {
  if (typeof window !== "undefined") return window.location.origin;
  return process.env.BACKEND_URL ?? "http://localhost:4000";
}

export const api = createBrickQueryClient({
  baseUrl: resolveBaseUrl(),
  contract,
});

export const ticketApi = api.ticket;
export type Ticket = Awaited<ReturnType<typeof ticketApi.get>>;
export type TicketCreateInput = Parameters<typeof ticketApi.create>[0];
export type TicketUpdateInput = Parameters<typeof ticketApi.update>[0];
export type TicketListItem = Awaited<
  ReturnType<typeof ticketApi.list>
>["items"][number];

The client mirrors the structure of your backend:

  • CRUD resources sit at the root (api.ticket.list, api.ticket.create).
  • Custom service actions appear under their service name (api.support.<action>).
  • Each method has TanStack Query helpers: .queryOptions() returns { queryKey, queryFn }, .mutationOptions() returns { mutationKey, mutationFn }, and .queryKey() produces typed cache filter keys.

Because the contract preserves return types, you can derive frontend types such as Ticket from the client instead of maintaining TypeScript interfaces by hand.

Build the ticket interface

Now build the React UI. The component lists tickets, creates new ones, updates ticket status, and renders typed errors.

Create src/TicketApp.tsx:

import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { InferActionFailure } from "@brickkit/core/client";
import { api, type Ticket } from "./lib/api";

export function TicketApp() {
  const queryClient = useQueryClient();
  const [title, setTitle] = useState("");
  const [assignee, setAssignee] = useState("");
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  const {
    data,
    isLoading,
    error: queryError,
  } = useQuery({
    ...api.ticket.list.queryOptions({
      input: {
        orderBy: [{ field: "title", direction: "asc" }],
      },
    }),
  });

  const createMutation = useMutation({
    ...api.ticket.create.mutationOptions(),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: api.ticket.list.queryKey() });
      setTitle("");
      setAssignee("");
      setErrorMessage(null);
    },
    onError: (err: unknown) => {
      if (err instanceof Error && err.name === "ValidationError") {
        const validationErr = err as {
          errors?: Array<{ path: string; message: string }>;
        };
        const details = validationErr.errors
          ?.map((e) => `${e.path}: ${e.message}`)
          .join(", ");
        setErrorMessage(`Validation error: ${details ?? err.message}`);
      } else if (err instanceof Error && err.name === "ActionExecutionError") {
        const actionErr = err as InferActionFailure<unknown>;
        setErrorMessage(`[${actionErr.code}] ${actionErr.message}`);
      } else {
        setErrorMessage(
          err instanceof Error ? err.message : "An unexpected error occurred",
        );
      }
    },
  });

  const updateMutation = useMutation({
    ...api.ticket.update.mutationOptions(),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: api.ticket.list.queryKey() });
      setErrorMessage(null);
    },
    onError: (err: unknown) => {
      if (err instanceof Error && err.name === "ActionExecutionError") {
        const actionErr = err as InferActionFailure<unknown>;
        setErrorMessage(`[${actionErr.code}] ${actionErr.message}`);
      } else {
        setErrorMessage(
          err instanceof Error ? err.message : "An unexpected error occurred",
        );
      }
    },
  });

  const handleCreate = (e: React.FormEvent) => {
    e.preventDefault();
    createMutation.mutate({
      title,
      status: "open",
      assignee: assignee || null,
    });
  };

  const handleToggleStatus = (ticket: Ticket) => {
    const nextStatus =
      ticket.status === "open"
        ? "in_progress"
        : ticket.status === "in_progress"
          ? "resolved"
          : "open";

    updateMutation.mutate({
      id: ticket.id,
      data: { status: nextStatus },
    });
  };

  const handleTestNotFound = () => {
    updateMutation.mutate({
      id: "non-existent-id",
      data: { status: "resolved" },
    });
  };

  return (
    <main
      style={{ maxWidth: 640, margin: "2rem auto", fontFamily: "sans-serif" }}
    >
      <h1>Support Tickets</h1>

      <form onSubmit={handleCreate} style={{ marginBottom: "1.5rem" }}>
        <div>
          <label htmlFor="title">Title: </label>
          <input
            id="title"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            placeholder="e.g. Fix login session timeout"
          />
        </div>
        <div style={{ marginTop: "0.5rem" }}>
          <label htmlFor="assignee">Assignee: </label>
          <input
            id="assignee"
            value={assignee}
            onChange={(e) => setAssignee(e.target.value)}
            placeholder="e.g. alice"
          />
        </div>
        <div style={{ marginTop: "0.5rem" }}>
          <button type="submit" disabled={createMutation.isPending}>
            {createMutation.isPending ? "Creating..." : "Create Ticket"}
          </button>
          <button
            type="button"
            onClick={handleTestNotFound}
            style={{ marginLeft: "0.5rem" }}
          >
            Test NOT_FOUND Error
          </button>
        </div>
      </form>

      {errorMessage && (
        <div
          style={{
            padding: "0.75rem",
            background: "#fee2e2",
            color: "#991b1b",
            marginBottom: "1rem",
          }}
        >
          {errorMessage}
        </div>
      )}

      {queryError && (
        <div
          style={{
            padding: "0.75rem",
            background: "#fee2e2",
            color: "#991b1b",
          }}
        >
          Failed to load tickets: {queryError.message}
        </div>
      )}

      {isLoading ? (
        <p>Loading tickets...</p>
      ) : (
        <ul style={{ listStyle: "none", padding: 0 }}>
          {data?.items.map((ticket) => (
            <li
              key={ticket.id}
              style={{
                display: "flex",
                justifyContent: "space-between",
                padding: "0.5rem 0",
                borderBottom: "1px solid #e5e7eb",
              }}
            >
              <div>
                <strong>{ticket.title}</strong>
                <span style={{ marginLeft: "0.5rem", color: "#6b7280" }}>
                  ({ticket.status})
                </span>
                {ticket.assignee && (
                  <span style={{ marginLeft: "0.5rem", color: "#3b82f6" }}>
                    @{ticket.assignee}
                  </span>
                )}
              </div>
              <button
                type="button"
                onClick={() => handleToggleStatus(ticket)}
                disabled={updateMutation.isPending}
              >
                Advance Status
              </button>
            </li>
          ))}
        </ul>
      )}
    </main>
  );
}

Mount the React component with a QueryClientProvider in src/main.tsx:

import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TicketApp } from "./TicketApp";

const queryClient = new QueryClient();

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <TicketApp />
    </QueryClientProvider>
  </React.StrictMode>,
);

Create index.html at the project root:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Support Tickets</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Handle errors in the client

The client throws on failure instead of returning { data, error } tuples, so you handle errors with try/catch or your query library's error state:

  • When an action violates a TypeBox schema, the server returns HTTP 400 with name: "ValidationError". The client turns this into an error whose err.errors holds the schema error objects.
  • When an action throws a framework error or a declared code (such as NOT_FOUND for a missing ticket ID), the server returns JSON with name: "ActionExecutionError", code, status, and message.
  • Network failures and unparsable responses produce BrickTransportError. Unhandled server crashes produce BrickServerError.

The ValidationError and ActionExecutionError classes live in the @brickkit/core server runtime, and the browser SDK does not export them, so that no backend code is pulled into the client. It does export BrickTransportError, BrickServerError, and the isBrickTransportError and isBrickServerError guards. In browser components, check err.name to narrow the first two:

if (err instanceof Error && err.name === "ValidationError") {
  // Schema validation failure
} else if (err instanceof Error && err.name === "ActionExecutionError") {
  // Modelled domain error (e.g., NOT_FOUND, FORBIDDEN)
}

The "Test NOT_FOUND Error" button sends an update request for the ID non-existent-id. The server responds with status 404, the client throws ActionExecutionError, and the UI shows:

[NOT_FOUND] Resource not found

Run the backend and frontend together

There are two ways to run the backend and the frontend during development.

Coordinated CLI workflow

To start both with one command, use the CLI's dev runner:

bunx brick dev src/app.ts --port 4000 --client _brick/contract.ts --frontend 'bun run dev'

With these flags:

  1. --client <file> generates _brick/contract.ts before the server starts.
  2. --frontend <command> starts the frontend process after the initial contract write succeeds.
  3. The supervisor watches the entry file's directory, src. If you change a backend schema, the worker restarts and atomically updates the contract file while the frontend keeps running. Any edit under src, frontend files included, restarts the worker and empties the in-memory table.

Two terminals

To see each process's output separately, run the backend and the Vite dev server independently:

Terminal 1 (Backend):

bunx brick dev src/app.ts --port 4000

Terminal 2 (Frontend):

bun run dev

The Vite dev server starts on http://localhost:5173, loads src/app.ts through brickClient, and proxies API traffic to http://localhost:4000.

Catch schema changes at compile time

Because the frontend types come from the backend contract, a backend schema change reaches the frontend compiler immediately.

In src/app.ts, rename the title field to subject in both the table definition and the CRUD fields:

export const tickets = sqliteTable("tickets", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => crypto.randomUUID()),
  subject: text("subject").notNull(),
  status: text("status").notNull().default("open"),
  assignee: text("assignee"),
});

// ...
fields: {
  id: { read: true, filter: ["eq", "in"] },
  subject: {
    read: true,
    create: true,
    update: true,
    filter: ["eq", "contains"],
    sort: true,
  },
  status: { read: true, create: true, update: true, filter: ["eq"], sort: true },
  assignee: { read: true, create: true, update: true, filter: ["eq"], sort: true },
},

Regenerate the contract (or let Vite regenerate it on save):

bunx brick gen client src/app.ts

Run the TypeScript type checker:

bun run typecheck

The compiler sees that the frontend is out of sync with the backend and reports three errors in src/TicketApp.tsx (your line numbers depend on how you formatted the file):

src/TicketApp.tsx:19:21 - error TS2322: Type '"title"' is not assignable to type '"assignee" | "status" | "subject"'.

19         orderBy: [{ field: "title", direction: "asc" }],
                       ~~~~~

src/TicketApp.tsx:73:7 - error TS2353: Object literal may only specify known properties, and 'title' does not exist in type '{ subject: string; status?: string | undefined; assignee?: string | null | undefined; }'.

73       title,
         ~~~~~

src/TicketApp.tsx:179:33 - error TS2339: Property 'title' does not exist on type 'BrickSchema_1c2741bab6bc'.

179                 <strong>{ticket.title}</strong>
                                    ~~~~~

Found 3 errors in the same file, starting at: src/TicketApp.tsx:19

Every place the frontend uses the field now fails at build time:

  1. Query sorting: the sort field 'title' is no longer a valid sort column.
  2. Mutation input: the create payload cannot accept 'title' when 'subject' is required.
  3. Response rendering: the list items returned by ticket.list no longer have a title property.

To fix them, use subject instead of title in src/TicketApp.tsx:

// Query sorting
orderBy: [{ field: "subject", direction: "asc" }];

// Mutation payload (the `title` state variable can keep its name)
createMutation.mutate({
  subject: title,
  status: "open",
  assignee: assignee || null,
});

// List rendering
<strong>{ticket.subject}</strong>;

Run the typecheck script again:

bun run typecheck

The command now exits with code 0.

Where to go next