Getting Started

Build a fully typed API with server, client, and OpenAPI docs — from a single schema definition.

# Server + Schema
$npm install @cleverbrush/server @cleverbrush/schema
# Client
$npm install @cleverbrush/client

These two packages are the minimum for a typed server + client setup. Add @cleverbrush/auth, @cleverbrush/di, and @cleverbrush/server-openapiwhen you're ready.

Step 1 — Define your schemas

Schemas are the single source of truth. Define your data shapes once — TypeScript types are inferred automatically, and the same schemas power validation, endpoint typing, and OpenAPI generation.

import { object, string, number, array } from '@cleverbrush/schema';

const Todo = object({
  id:        number(),
  title:     string().minLength(1).maxLength(200),
  completed: boolean()
});

const CreateTodoBody = object({
  title: string().minLength(1).maxLength(200)
});

// TypeScript types flow from the schema — never duplicated
// type Todo = { id: number; title: string; completed: boolean }
// type CreateTodoBody = { title: string }

Step 2 — Create the API contract

The contract defines your endpoints in a shared module. Both server and client import it — ensuring they always agree on the API shape.

import { defineApi, endpoint, route } from '@cleverbrush/server/contract';

// Reusable typed path parameter: /api/todos/:id (id coerced to number)
const idRoute = route({ id: number().coerce() })`/${t => t.id}`;

// Scoped factory — all methods share the /api/todos base path
const todos = endpoint.resource('/api/todos');

export const api = defineApi({
  todos: {
    // GET /api/todos
    list:   todos.get().returns(array(Todo)),
    // POST /api/todos
    create: todos.post().body(CreateTodoBody).returns(Todo),
    // GET /api/todos/:id
    get:    todos.get(idRoute).returns(Todo),
    // PATCH /api/todos/:id
    toggle: todos.patch(idRoute).returns(Todo),
    // DELETE /api/todos/:id
    delete: todos.delete(idRoute)
  }
});

endpoint.resource() scopes all methods to the same base path, eliminating repetition. The route() tagged template provides type-safe path parameters — params.id is typed as number and coerced automatically.

Step 3 — Implement the server

mapHandlers()maps every endpoint to a handler. If you forget one, TypeScript produces a compile error — you can't ship an incomplete API.

import { createServer, mapHandlers, ActionResult } from '@cleverbrush/server';
// Shared contract — same file imported by both server and client
import { api } from './contract';

const server = createServer();

// In-memory store for this example
let todos = [{ id: 1, title: 'Try Cleverbrush', completed: false }];
let nextId = 2;

mapHandlers(server, api, {
  todos: {
    list:   async () => todos,
    create: async ({ body }) => {
      const todo = { id: nextId++, title: body.title, completed: false };
      todos.push(todo);
      return todo;
    },
    get: async ({ params }) => {
      const todo = todos.find(t => t.id === params.id);
      if (!todo) return ActionResult.notFound();
      return todo;
    },
    toggle: async ({ params }) => {
      const todo = todos.find(t => t.id === params.id);
      if (!todo) return ActionResult.notFound();
      todo.completed = !todo.completed;
      return todo;
    },
    delete: async ({ params }) => {
      todos = todos.filter(t => t.id !== params.id);
    }
  }
});

await server.listen(3000);
console.log('Server running on http://localhost:3000');

Request bodies are validated automatically against the schema. Invalid requests are rejected with structured RFC 9457 Problem Details responses — no manual validation code needed.

Step 4 — Create the typed client

The client reads the contract and gives you a fully typed API — no codegen, no manual type annotations.

import { createClient } from '@cleverbrush/client';
// Shared contract — same file imported by both server and client
import { api } from './contract';

const client = createClient(api, {
  baseUrl: 'http://localhost:3000'
});

// Every call is fully typed — wrong shapes are compile errors
const todos = await client.todos.list();
//    ^ { id: number; title: string; completed: boolean }[]

const newTodo = await client.todos.create({
  body: { title: 'Learn Cleverbrush' }
});

const todo = await client.todos.get({ params: { id: 1 } });

await client.todos.toggle({ params: { id: 1 } });

await client.todos.delete({ params: { id: 1 } });

With React Query

Use @cleverbrush/client/react to get TanStack Query hooks co-located on the same client object — no separate query-key management.

import { createClient } from '@cleverbrush/client/react';
// Shared contract — same file imported by both server and client
import { api } from './contract';

export const client = createClient(api, { baseUrl: 'http://localhost:3000' });

function TodoList() {
  // useQuery — reactive fetch, auto-refetch on focus / reconnect
  const { data: todos } = client.todos.list.useQuery();

  // useMutation — typed body, invalidate the whole group on success
  const create = client.todos.create.useMutation({
    onSuccess: () => queryClient.invalidateQueries({
      queryKey: client.todos.queryKey()
    })
  });

  return (
    <>
      {todos?.map(t => <li key={t.id}>{t.title}</li>)}
      <button onClick={() => create.mutate({ body: { title: 'New todo' } })}>
        Add
      </button>
    </>
  );
}

With WebSockets

Define a subscription in your contract, then consume it with the useSubscription hook — connection lifecycle is managed automatically.

// contract.ts — add a subscription alongside regular endpoints
import { endpoint } from '@cleverbrush/server/contract';

const TodoEvent = object({ type: string(), todo: Todo });

export const api = defineApi({
  todos: { /* ...existing endpoints... */ },
  live: {
    // WS /ws/todos — server pushes TodoEvent, client can send void
    events: endpoint
      .subscription('/ws/todos')
      .outgoing(TodoEvent)
  }
});

// ── React component ──────────────────────────────────────────────
import { useSubscription } from '@cleverbrush/client/react';
// Shared contract — same file imported by both server and client
import { client } from './client';

function LiveTodos() {
  const { events, state } = useSubscription(
    () => client.live.events(),
    { maxEvents: 50 }
  );

  return (
    <>
      <p>Status: {state}</p>
      {events.map((e, i) => (
        <li key={i}>{e.type}: {e.todo.title}</li>
      ))}
    </>
  );
}

Step 5 — Add client resilience

The client has built-in retry, timeout, deduplication, and caching — configurable globally or per call.

import { createClient, withRetry, withTimeout, withDeduplication } from '@cleverbrush/client';
// Shared contract — same file imported by both server and client
import { api } from './contract';

const client = createClient(api, {
  baseUrl: 'http://localhost:3000',
  middleware: [
    withRetry({ limit: 3, delay: 1000, backoffLimit: 10000, jitter: true }),
    withTimeout(5000),
    withDeduplication()
  ]
});

// Per-call override
const todo = await client.todos.get(
  { params: { id: 1 } },
  { retry: { limit: 5 }, timeout: 10000 }
);

Step 6 — Generate OpenAPI docs

Add one middleware and get a full OpenAPI 3.1 spec with Swagger UI — always accurate, always in sync with your types.

import { openapi } from '@cleverbrush/server-openapi';

server.use(openapi({
  info: { title: 'Todo API', version: '1.0.0' },
  swaggerUi: '/docs'       // Swagger UI at http://localhost:3000/docs
  // OpenAPI JSON at       // http://localhost:3000/openapi.json
}));

The spec includes typed request/response schemas, path parameters, error responses, and security schemes. See the OpenAPI docs for advanced features like response links, callbacks, and webhooks.

Next steps

You now have a fully typed API with server, client, and OpenAPI docs. Here's where to go next:

TopicWhat you'll learn
ServerEndpoint builder API, action results, middleware, WebSockets, batching
ClientMiddleware chain, retry, timeout, dedup, cache, batching, error handling
AuthJWT, cookies, OAuth2, OIDC, role-based policies, server integration
Dependency InjectionSchemas as service keys, lifetimes, function injection, disposal
React IntegrationTanStack Query hooks, typed queryKeys, mutations, Suspense, prefetching
React FormSchema-driven forms with headless rendering and type-safe field selectors
OpenAPILinks, callbacks, webhooks, security schemes, response headers
Schema LibraryThe foundation — all builders, extensions, generics, playground
Example AppFull-stack Todo app with auth, DI, OpenAPI, React frontend