Skip to content
rest-rpc
Esc
navigateopen⌘Jpreview
On this page

Procedure Routes

Build type-safe function-like APIs without declaring HTTP details for every operation.

Procedure routes are the shortest path from a TypeScript function to a typed HTTP client. Declare an input and output—or attach a handler directly—and rest-rpc derives the transport details from the route tree.

const api = {
	todos: {
		get: route.input(z.object({ id: z.string() })).output(todoSchema),
	},
};
const todo = await client.todos.get({ id: "todo_1" });

Use procedure routes as the starting point for application-owned APIs when an operation has one normal JSON result and callers should not need to work with HTTP response metadata. Use an explicit HTTP route when the HTTP interface itself is part of the design.

Start with a procedure route when Use an explicit route when
The API is called by your own TypeScript applications The API is public or designed HTTP-first
The operation has one successful JSON result Callers handle expected statuses such as 404 or 409
A function-shaped client is the primary interface Method, URL, headers, or caching semantics matter
Errors are exceptional rather than part of the typed result You need typed error bodies or response headers
Input fits in one JSON value You need path, query, form, file, stream, SSE, or WebSocket data

Procedure and explicit routes can coexist in the same tree. Starting with a procedure does not commit the whole API to that style.

Contract-First Procedures

In a shared contract, .input() declares the JSON input and .output() declares the successful JSON result. An output is required; input is optional.

import { route } from "@rest-rpc/core";
import { z } from "zod";

const todoSchema = z.object({
	id: z.string(),
	title: z.string(),
});

export const api = {
	todos: {
		list: route.output(z.array(todoSchema)),
		create: route
			.input(z.object({ title: z.string().min(1) }))
			.output(todoSchema),
	},
};

Implement the contract with the Node or Fetch runtime adapter.

import { createRouteHandler, implement } from "@rest-rpc/node";
import { api } from "./contract";

const builders = implement(api);

export const routes = {
	todos: {
		list: builders.todos.list.handler(() => listTodos()),
		create: builders.todos.create.handler(({ title }) => createTodo(title)),
	},
};

const handle = createRouteHandler(routes);

The schema validates server input and can validate output according to the same adapter options used by explicit routes.

Server-First Procedures

With a server-first API, end the procedure with .handler(). The handler return type becomes the client result type, so an output schema is optional.

import { route } from "@rest-rpc/node";
import { z } from "zod";

export const routes = {
	todos: {
		list: route.handler(() => listTodos()),
		create: route
			.input(z.object({ title: z.string().min(1) }))
			.handler(({ title, context }) => {
				return createTodo(title, context.signal);
			}),
	},
};

Add .output(schema) before .handler() when the output should be validated at runtime or when a stable schema should constrain the implementation.

const create = route
	.input(z.object({ title: z.string().min(1) }))
	.output(todoSchema)
	.handler(({ title }) => createTodo(title));

Input and output may be declared in either order. context contains the same adapter context and AbortSignal available to explicit server-first routes.

Call Procedures

The contract-first and server-first clients have the same procedure-shaped surface. A procedure returns its output directly instead of an HTTP response envelope.

import { initClient } from "@rest-rpc/core";
import type { routes } from "./server";

const client = initClient<typeof routes>({
	baseUrl: "https://api.example.com",
});

const todos = await client.todos.list();
const created = await client.todos.create({ title: "Write docs" });

Pass Fetch options as the second argument. For a procedure without input, pass undefined first.

await client.todos.list(undefined, { cache: "no-store" });

An unsuccessful HTTP response rejects the call with an Error. Procedure routes do not declare typed error results. If an error is an expected part of the API that clients should inspect, model it with explicit response statuses on an HTTP route.

TanStack Query

Procedure helpers also return direct data. Use queryOptions() for reads and mutationOptions() for writes according to how the operation is used by the application; every procedure uses POST on the wire.

const todos = useQuery(tq.todos.list.queryOptions());

const createTodo = useMutation(tq.todos.create.mutationOptions());
createTodo.mutate({ title: "Write docs" });

queryOptions() and mutationOptions() both remain available for procedures with input. This is an application-level choice, not an inference from the HTTP method.

Derived HTTP Interface

The object keys determine the URL, and the transport profile is fixed:

api.todos.create  ->  POST /todos/create
  • The input, when present, is sent as a JSON request body.
  • A successful result is a 200 JSON response.
  • The client returns the decoded result directly.
  • OpenAPI generation emits the derived POST operation.
  • Per-call Fetch options and global client headers still apply.

Renaming a tree key changes the URL. Treat those keys as part of the wire API if clients and servers are deployed independently.

route.with() configures explicit route factories and does not change the fixed procedure profile. Switch that operation to an explicit route when it needs custom paths, shared responses, metadata, or other HTTP controls.

Adapter Support

Procedure routes and server-first handlers are served by the runtime adapters:

Adapter Procedure routes Server-first Explicit contract-first routes
@rest-rpc/node Yes Yes Yes
@rest-rpc/fetch Yes Yes Yes
@rest-rpc/express No No Yes
@rest-rpc/fastify No No Yes
@rest-rpc/hono No No Yes
@rest-rpc/nest No No Yes

The Node and Fetch packages are full runtime routers. They can also be mounted inside compatible frameworks, as shown in Node HTTP and Fetch Runtime. The framework-specific packages preserve native registration, context, middleware, and dependency-injection models for explicit contract-first routes.

Choose the integration for the behavior you need; the packages do not all expose the same authoring model.

Was this page helpful?