API reference
Publish API documentation from oRPC contracts
Generate an OpenAPI document from your oRPC contract and publish it as an API reference, with a page per operation that updates when the contract changes.
By Hayden Bleasel11 min read

To publish REST documentation from oRPC contracts, give each procedure a method, path, and summary with .route(), generate an OpenAPI 3.1 document from the contract with oRPC's OpenAPIGenerator and its Zod converter, and point Blume's openapi() reference at the file. Blume turns each operation into its own page, with parameter and schema tables, code samples, and a Try it panel.
By the end, you have a small oRPC server for a fictional Acme Messages API, a script that writes openapi.json from its contract, and a docs site that renders the reference at /api beside the pages you write. The last step changes the contract and follows the change through to the published pages.
The guide uses contract-first oRPC with Zod 4. A router built with os works the same way, since the generator accepts a router too. If one reference page served by your API is enough, you may not need a docs site at all: oRPC's OpenAPIReferencePlugin serves a Scalar UI and the spec straight from the server. Blume is for when the reference belongs in a documentation site, with guides, search, and llms.txt around it.
RPC paths and documented paths
oRPC can serve the same router two ways, and only one of them belongs in your docs.
RPCHandlerspeaks oRPC's own protocol, for oRPC's typed client. Its paths come from the router's keys, like/rpc/messages/send, and it wraps bodies in its own envelope ({"json": ...}). oRPC's docs warn against calling it by hand, and it never appears in the OpenAPI document.OpenAPIHandlerserves plain REST at the method and path you give each procedure, likePOST /v1/messages, with plain JSON bodies. This is the API the generated document describes.
Three things follow from that:
- A path in the spec is the procedure's
path. The prefix you mount the handler at (/v1here) goes in the spec'sserversURL, not in every path. - A procedure with no
pathis still documented, as aPOSTto a path built from its router keys, like/messages/send, with the operation IDmessages.send. Give every public procedure an explicit method and path, so your REST URLs are a decision rather than a side effect of how you nested the router. - With oRPC's default input structure, fields named in the path become path parameters. The rest become query parameters on a
GETand the JSON body on aPOST.
Set up the project
Start a project for the API, and install oRPC, Zod, and tsx to run the TypeScript:
mkdir acme-messages-api && cd acme-messages-api
npm init -y
npm pkg set type=module
npm install @orpc/contract@1.15.4 @orpc/server@1.15.4 @orpc/openapi@1.15.4 @orpc/zod@1.15.4 zod@4.6.5
npm install --save-dev tsx@4.23.15In an existing oRPC project, add only what's missing, usually @orpc/openapi and @orpc/zod. Then add two scripts to package.json, one to run the API and one to write its spec:
{
"scripts": {
"dev": "tsx watch src/server.ts",
"openapi": "tsx scripts/openapi.ts"
}
}The docs get a Blume project of their own in docs-site/, with its own package.json. That keeps Blume's dependencies out of the API's install, keeps its dist/ apart from any build of the API, and lets your docs host build the docs without the API. Scaffold it now, since the spec script writes into it:
npx blume init docs-site --template docs --yesThat writes docs-site/ with a blume.config.ts, a docs/index.mdx home page, and a package.json whose dev and build scripts run Blume, then installs Blume there.
Add route metadata to the contract
The contract is where the documentation comes from. Each procedure gets its REST shape and its prose from .route(), and each field gets its description and example from the Zod schema:
import { oc } from "@orpc/contract";
import * as z from "zod";
const Channel = z.enum(["email", "sms"]).describe("How to deliver the message.");
export const Message = z.object({
id: z.string().meta({ examples: ["msg_8f2k"] }),
status: z.enum(["queued", "sent", "delivered", "failed"]),
channel: Channel,
});
const SendMessageInput = z.object({
channel: Channel,
to: z.string().meta({
description: "An email address, or a phone number in E.164 format.",
examples: ["ada@example.com"],
}),
template: z.string().meta({
description: "The ID of the template to send.",
examples: ["welcome"],
}),
variables: z
.record(z.string(), z.string())
.optional()
.describe("Values for the template's variables."),
});
const MessageId = z.object({
id: z.string().meta({
description: "The message ID, from the response to Send a message.",
examples: ["msg_8f2k"],
}),
});
export const contract = oc.tag("Messages").router({
messages: {
send: oc
.route({
method: "POST",
path: "/messages",
operationId: "sendMessage",
summary: "Send a message",
description:
"Queues an email or SMS for delivery and returns its ID right away.",
successStatus: 202,
successDescription: "The message is queued.",
})
.input(SendMessageInput)
.output(Message),
get: oc
.route({
method: "GET",
path: "/messages/{id}",
operationId: "getMessage",
summary: "Get a message",
description: "Returns a message and its delivery status.",
})
.errors({ NOT_FOUND: { message: "No message has that ID." } })
.input(MessageId)
.output(Message),
},
});Here's where each piece ends up on the docs site:
| In the contract | On the Blume page |
|---|---|
method and path | The endpoint on the page and in search, and what Try it calls |
operationId | The page's URL: sendMessage becomes send-message |
oc.tag("Messages") | The sidebar group, and the messages segment of the URL |
summary | The page title and sidebar label |
description | The page's introduction and its search result description |
successStatus, successDescription | The success response: 202, "The message is queued." |
.errors() | An error response, here a 404 with oRPC's error body |
.describe() and .meta() | Field descriptions in the schema tables, and the values Try it starts with |
.describe() sets only a description. .meta() sets a description and examples together, which is worth doing for any field a reader has to fill in.
Implement and serve it
implement() turns the contract into a builder for handlers, and TypeScript holds the router to it. The middleware rejects requests without a bearer token, so the API enforces what the docs will say about authentication:
import type { IncomingHttpHeaders } from "node:http";
import { implement, ORPCError } from "@orpc/server";
import type * as z from "zod";
import { contract, type Message } from "./contract.ts";
const os = implement(contract).$context<{ headers: IncomingHttpHeaders }>();
const authed = os.use(({ context, next }) => {
if (!context.headers.authorization?.startsWith("Bearer ")) {
throw new ORPCError("UNAUTHORIZED");
}
return next();
});
const messages = new Map<string, z.infer<typeof Message>>();
export const router = authed.router({
messages: {
send: authed.messages.send.handler(({ input }) => {
const message = {
id: `msg_${crypto.randomUUID().slice(0, 8)}`,
status: "queued" as const,
channel: input.channel,
};
messages.set(message.id, message);
return message;
}),
get: authed.messages.get.handler(({ input, errors }) => {
const message = messages.get(input.id);
if (!message) {
throw errors.NOT_FOUND();
}
return message;
}),
},
});The server mounts the REST handler at /v1 and the RPC handler at /rpc. The CORS plugin lets the docs dev server, on port 4321, call the API from Try it later on:
import { createServer } from "node:http";
import { OpenAPIHandler } from "@orpc/openapi/node";
import { RPCHandler } from "@orpc/server/node";
import { CORSPlugin } from "@orpc/server/plugins";
import { router } from "./router.ts";
const rest = new OpenAPIHandler(router, {
plugins: [new CORSPlugin({ origin: "http://localhost:4321" })],
});
const rpc = new RPCHandler(router);
const server = createServer(async (req, res) => {
const context = { headers: req.headers };
const restResult = await rest.handle(req, res, { prefix: "/v1", context });
if (restResult.matched) {
return;
}
const rpcResult = await rpc.handle(req, res, { prefix: "/rpc", context });
if (rpcResult.matched) {
return;
}
res.statusCode = 404;
res.end("Not found");
});
server.listen(3000, () => {
console.log("Listening on http://localhost:3000");
});Run npm run dev, then call the same procedure both ways from another terminal:
curl -X POST http://localhost:3000/v1/messages \
-H "Authorization: Bearer test" \
-H "Content-Type: application/json" \
-d '{"channel":"email","to":"ada@example.com","template":"welcome"}'
curl -X POST http://localhost:3000/rpc/messages/send \
-H "Authorization: Bearer test" \
-H "Content-Type: application/json" \
-d '{"json":{"channel":"email","to":"ada@example.com","template":"welcome"}}'The first answers 202 with the message as plain JSON, like {"id":"msg_5815f40e","status":"queued","channel":"email"}. The second answers 200 with the same message inside oRPC's json envelope. Both run the same handler, but only the first is the API you're documenting. Leave out the token and the REST call answers 401.
Generate the OpenAPI document
The generator reads the contract, not the server, so this script never starts the API or touches its handlers:
import { writeFile } from "node:fs/promises";
import { OpenAPIGenerator } from "@orpc/openapi";
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import { contract } from "../src/contract.ts";
const generator = new OpenAPIGenerator({
schemaConverters: [new ZodToJsonSchemaConverter()],
});
const spec = await generator.generate(contract, {
info: {
title: "Acme Messages API",
version: "1.0.0",
description: "Send transactional email and SMS.",
},
servers: [{ url: "https://api.acme.example/v1" }],
tags: [
{
name: "Messages",
description: "Send a message and check whether it arrived.",
},
],
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
description: "An API key, sent as a bearer token.",
},
},
},
security: [{ bearerAuth: [] }],
});
// oRPC writes a path or query field's description on the parameter's
// schema. Copy it onto the parameter, where the parameter tables read it.
for (const pathItem of Object.values(spec.paths ?? {})) {
for (const method of ["get", "put", "post", "patch", "delete"] as const) {
for (const parameter of pathItem?.[method]?.parameters ?? []) {
if ("in" in parameter && parameter.schema && "description" in parameter.schema) {
parameter.description ??= parameter.schema.description;
}
}
}
}
await writeFile("docs-site/openapi.json", `${JSON.stringify(spec, null, 2)}\n`);
console.log("Wrote docs-site/openapi.json");What each part does:
schemaConvertersturns Zod schemas into JSON Schema. Zod 4 uses the converter from@orpc/zod/zod4; the one from@orpc/zodis for Zod 3. Valibot and ArkType have their own.- Everything else passed to
generate()is copied into the document. The server URL carries the/v1prefix, the tag's description becomes the paragraph under its section on the reference's overview page, and the rootsecurityputs an Authorization section on every operation. - The loop copies each path and query field's description up to the parameter. Without it, the
idparameter on Get a message shows no description.
Run it:
npm run openapiThe file starts "openapi": "3.1.1", the version oRPC 1 writes. That's the version Blume renders: it upgrades Swagger 2.0 and OpenAPI 3.0 to 3.1 as it reads them, but it doesn't convert 3.2, and it has no page for 3.2's QUERY method.
Render the reference with Blume
Replace docs-site/blume.config.ts. It mounts the reference at /api and adds header tabs, since a reference never adds a tab on its own:
import { defineConfig } from "blume";
import { openapi } from "blume/reference";
export default defineConfig({
title: "Acme Docs",
navigation: {
tabs: [
{ label: "Docs", path: "/" },
{ label: "API reference", path: "/api" },
],
},
reference: [openapi({ route: "/api", spec: "./openapi.json" })],
});Replace the scaffolded home page with one that points into the reference:
---
title: Acme Messages API
description: Send transactional email and SMS from your app with the Acme Messages API.
---
The Acme Messages API sends transactional email and SMS. Every request needs
an API key, sent as a bearer token.
Start with [Send a message](/api/messages/send-message), then check whether it
arrived with [Get a message](/api/messages/get-message).Start the docs:
cd docs-site
npm run devThe API reference tab opens an overview at /api, with the API's version, its server URL, and a Messages section listing both operations. Each has its own page:
| Procedure | Endpoint | Page |
|---|---|---|
messages.send | POST /messages | /api/messages/send-message |
messages.get | GET /messages/{id} | /api/messages/get-message |
To try a request against your local server, keep the API's npm run dev running in another terminal, open Try it on Send a message, and enter http://localhost:3000/v1 as the Custom base URL. Any token works, since the example only checks that one is there. The CORS plugin allows the dev server's origin. For your production docs, add their origin to the plugin or turn on Blume's playground proxy.
For the rest of the reference, like choosing code sample languages and adding hand-written pages beside the generated ones, see Generate API docs from an OpenAPI spec. To publish, commit docs-site/openapi.json and point your host at the docs-site folder, with npm run build as the build command and dist as the output; the deployment docs cover each host.
Change the contract, update the docs
Say the API gains a way to cancel a queued message. The change starts in the contract. Add a status to Message:
status: z.enum(["queued", "sent", "delivered", "failed", "canceled"]),Then add the procedure after get, inside messages:
cancel: oc
.route({
method: "POST",
path: "/messages/{id}/cancel",
operationId: "cancelMessage",
summary: "Cancel a message",
description: "Stops a queued message before it's sent.",
})
.errors({ NOT_FOUND: { message: "No message has that ID." } })
.input(MessageId)
.output(Message),TypeScript now fails on router.ts with "Property 'cancel' is missing", because the router no longer matches the contract. Add the handler after get:
cancel: authed.messages.cancel.handler(({ input, errors }) => {
const message = messages.get(input.id);
if (!message) {
throw errors.NOT_FOUND();
}
message.status = "canceled";
return message;
}),Regenerate the spec from the API project, then restart the docs dev server. Blume reads the spec when blume dev starts and doesn't watch it, so a running dev server keeps showing the old one:
npm run openapi
cd docs-site
npm run devTwo things change on the site. A Cancel a message page appears at /api/messages/cancel-message, in the sidebar and in search. And canceled joins the allowed values for status on every page that returns a message, because each operation's schema is generated from the one Message schema.
Renames need more care. A page's URL comes from its tag and operation ID, so renaming sendMessage or the Messages tag moves the page. Add a redirect for the old URL:
redirects: [
{ from: "/api/messages/send-message", to: "/api/messages/create-message" },
],Commit docs-site/openapi.json with the contract change. Then every pull request that changes the contract shows the change to the public API as a diff a reviewer can read. A CI step that runs npm run openapi and then git diff --exit-code docs-site/openapi.json fails when someone forgets to regenerate. Keep API documentation in sync with backend changes in CI builds that into a full workflow.
Troubleshooting
Pages land under /api/operations with dotted names
A procedure with no operationId gets oRPC's default, its router keys joined with dots, like messages.send. With no tag, Blume files it under an Operations group, so the page ends up at /api/operations/messages-send with POST /messages/send as its title. Give each procedure an operationId and a summary, and tag the router with oc.tag().
Requests return 404 Not found
The request reached the server but no handler matched. Check that the base URL includes the prefix the REST handler is mounted at, so http://localhost:3000/v1 rather than http://localhost:3000, and that you're calling the documented path. The RPC path, like /v1/messages/send, doesn't exist on the REST handler once a procedure has its own path.
Send fails in the browser but curl works
That's CORS. The browser only lets the docs page call your API when the API allows the docs origin. Add the origin to CORSPlugin, which accepts an array, or use the playground proxy. Fix CORS errors in an API documentation playground walks through both.
The build fails with BLUME_OPENAPI_UNAVAILABLE
Blume couldn't read docs-site/openapi.json. Either spec points somewhere else (it's relative to docs-site/), or the file was never generated or committed, so the docs host's checkout doesn't have it. Run npm run openapi and commit the file. In blume dev the same problem is only a warning, and the reference is left out.
An error response is described only as "404"
oRPC labels each error response with its status code. To write your own description, extend the generated operation with spec in the procedure's .route():
spec: (operation) => ({
...operation,
responses: {
...operation.responses,
404: {
...operation.responses?.[404],
description: "No message has that ID.",
},
},
}),The spec says "openapi": "3.2.0"
You're on oRPC 2. Its generator writes OpenAPI 3.2 unless told otherwise, and Blume renders 3.1. Pass version: "3.1.1" to generate(). The other oRPC 2 changes: route metadata moves from .route(...) to .meta(openapi(...)), with openapi imported from @orpc/openapi; the generator's option is converters, with ZodToJsonSchemaConverter imported from @orpc/zod; and document fields like info and servers go inside base.
Next step
Publish your oRPC reference
Scaffold a docs site beside your oRPC project, generate the spec into it, and point the openapi() reference at the file.
npx blume init docs-site --template docs --yesA step here not working for you? Report a broken step.