Skip to content
rest-rpc
Esc
↑↓navigate↵open⌘Jpreview
On this page

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.

Was this page helpful?