Build a fully typed API with server, client, and OpenAPI docs — from a single schema definition.
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 }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.
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.
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 } });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>
</>
);
}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>
))}
</>
);
}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 }
);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.
You now have a fully typed API with server, client, and OpenAPI docs. Here's where to go next:
| Topic | What you'll learn |
|---|---|
| Server | Endpoint builder API, action results, middleware, WebSockets, batching |
| Client | Middleware chain, retry, timeout, dedup, cache, batching, error handling |
| Auth | JWT, cookies, OAuth2, OIDC, role-based policies, server integration |
| Dependency Injection | Schemas as service keys, lifetimes, function injection, disposal |
| React Integration | TanStack Query hooks, typed queryKeys, mutations, Suspense, prefetching |
| React Form | Schema-driven forms with headless rendering and type-safe field selectors |
| OpenAPI | Links, callbacks, webhooks, security schemes, response headers |
| Schema Library | The foundation — all builders, extensions, generics, playground |
| Example App | Full-stack Todo app with auth, DI, OpenAPI, React frontend |