Contract-first
Declare routes in a shared contract and implement them separately.
Import route from @rest-rpc/core to declare a route independently
of its server implementation. The contract can be shared directly with a Fetch
client and implemented with any server adapter.
The declaration methods are the same as the Route Builder. The difference is that outputs are declared with schemas rather than inferred from handlers.
Declare Routes
import { route } from "@rest-rpc/core";
import { z } from "zod";
const todoSchema = z.object({ id: z.string(), title: z.string() });
const create = route.input(z.object({ title: z.string() })).output(todoSchema);
const get = route
.get("/todos/:id")
.params(z.object({ id: z.string() }))
.response(200, todoSchema)
.response(404, z.object({ code: z.literal("TODO_NOT_FOUND") }));
export const api = { todos: { create, get } };
Use .output() for a plain result or .response() for status responses.
Streams use .streamOutput() or .streamResponse(). These schemas define
handler and client types and provide runtime validation.
See Streaming for stream handlers.
Implement Routes
Each server adapter exports implement(). Pass a route or route tree to get
builders with typed .handler() methods:
import { implement } from "@rest-rpc/express";
import { api } from "./contract";
import { createTodo, findTodo } from "./todos";
const implementer = implement(api);
const create = implementer.todos.create.handler(({ input: { title } }) =>
createTodo(title),
);
const get = implementer.todos.get.handler(({ params }) => {
const todo = findTodo(params.id);
return todo
? { status: 200, body: todo }
: { status: 404, body: { code: "TODO_NOT_FOUND" } };
});
export const routes = { todos: { create, get } };
The implementation must match the contract’s inputs and declared outputs. Register the completed routes with your adapter as usual.
Context And Middleware
Context and middleware are supported in contract-first routes. See Context and Middleware for details.
$context<T>() and .use() can be called on the root builder to share context and middleware across all routes:
const implementer = implement(api)
.$context<{ user: { id: string } }>()
.use(yourMiddleware);
const createTodo = implementer.todos.create.handler(yourHandler);
Alternatively, call them on a specific route to share context and middleware only with that route:
const implementer = implement(api);
const create = implementer.todos.create
.$context<{ user: { id: string } }>()
.use(yourMiddleware)
.handler(yourHandler);
Use in a Client
Pass the shared contract directly to initClient() or
createTanstackQueryUtils(). No contract generation
step is needed, and the client can validate responses
with the contract’s schemas.
See the Contract-first Quickstart for a complete example.