Quickstart
Define HTTP routes with handlers and call them through a typed client.
rest-rpc builds REST APIs with RPC-like ergonomics. Start with default
methods and paths, or declare request segments and response statuses explicitly.
Both forms are ordinary HTTP routes.
This guide uses Express and Zod to build two routes: a minimal create operation and an explicit GET with path params, query input, and success/error responses. Their output types are inferred from the handlers.
Install
npm install @rest-rpc/core @rest-rpc/express express zodpnpm add @rest-rpc/core @rest-rpc/express express zodyarn add @rest-rpc/core @rest-rpc/express express zodbun add @rest-rpc/core @rest-rpc/express express zodnub add @rest-rpc/core @rest-rpc/express express zodaube add @rest-rpc/core @rest-rpc/express express zodUse an existing TypeScript project with a tsconfig.json. For other frameworks
and runtimes, see the server adapters.
Define and Register Routes
import express from "express";
import { route, registerRoutes } from "@rest-rpc/express";
import { z } from "zod";
type Todo = { id: string; title: string; completed: boolean };
const todos = new Map<string, Todo>();
const create = route
.input(z.object({ title: z.string().min(1) }))
.handler(({ input: { title } }) => {
const todo: Todo = {
id: crypto.randomUUID(),
title,
completed: false,
};
todos.set(todo.id, todo);
return todo;
});
const get = route
.get("/todos/:id")
.params(z.object({ id: z.string() }))
.query(z.object({ uppercase: z.enum(["yes", "no"]).optional() }))
.handler(({ params, query }) => {
const todo = todos.get(params.id);
if (!todo) {
return { status: 404, body: { code: "TODO_NOT_FOUND" as const } };
}
return {
status: 200,
body: {
...todo,
title:
query.uppercase === "yes" ? todo.title.toUpperCase() : todo.title,
},
};
});
export const routes = { todos: { create, get } };
const app = express();
app.use(express.json());
registerRoutes(app, routes);
app.listen(3000);
todos.create uses POST /todos/create, sends its flat input as JSON, and
returns a plain result with status 200. todos.get uses
GET /todos/:id?uppercase=yes and returns a status envelope. The client keeps
these same input and result shapes.
The request schemas validate inputs, and the handler’s return value determines the client’s result type.
Create the Client
Derive the client definition from the exported routes:
import { generateContractFromType } from "@rest-rpc/core/generate";
import type { routes } from "./server";
export const api = generateContractFromType<typeof routes>();
For browser clients, run generation at build time. See Client Contract Generation for setup.
Call the Routes
import { initClient } from "@rest-rpc/core";
import { api } from "./client-contract";
const client = initClient(api, { baseUrl: "http://localhost:3000" });
const todo = await client.todos.create({ title: "Write docs" });
const response = await client.todos.get({
params: { id: todo.id },
query: { uppercase: "yes" },
});
if (response.status === 200) {
console.log(response.body.title);
} else {
console.log(response.body.code); // "TODO_NOT_FOUND"
}
The calls are typed functions, while the requests still use the methods, URLs, query parameters, and statuses you defined. Other HTTP clients can call the same endpoints.
Next Steps
- Route Builder covers explicit outputs, content types, streams, and metadata.
- Fetch Client covers client behavior and options.
- TanStack Query adds query and mutation utils.
- Contract-first Quickstart builds the same API from a separately shared contract.