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

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 zod
pnpm add @rest-rpc/core @rest-rpc/express express zod
yarn add @rest-rpc/core @rest-rpc/express express zod
bun add @rest-rpc/core @rest-rpc/express express zod
nub add @rest-rpc/core @rest-rpc/express express zod
aube add @rest-rpc/core @rest-rpc/express express zod

Use 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

Was this page helpful?