API reference
Generate documentation from Hono and Zod schemas
A Hono API whose Zod schemas validate requests and generate its OpenAPI spec, published as a docs site with a page per route and a CI check that keeps it current.
By Hayden Bleasel11 min read

To generate documentation from Hono and Zod schemas, declare each route once with @hono/zod-openapi, Hono's official OpenAPI middleware. The same Zod schemas validate every request at runtime and generate the OpenAPI document, so the request rules in your docs are the rules your API enforces. Export that document to a file, point Blume at it, and each route becomes a docs page. The docs stay in sync as long as the file is regenerated when a route changes, and a CI check can make sure it is.
By the end, you have a small Acme Messages API in Hono with two routes, validation errors in a documented shape, a script that writes openapi.json, and a Blume site with a page per route beside a hand-written errors guide. Every file is here in full.
If all you need is an interactive reference served by the API itself, Hono's @hono/swagger-ui middleware can render the same spec at a route in your app, with no docs site to run. Blume is for when the reference belongs next to written guides, with site search, a page per operation, and static hosting.
Choose one Hono OpenAPI integration
Two libraries generate OpenAPI from Hono routes, and they have different APIs:
@hono/zod-openapiis maintained in Hono's middleware repository. You define a route withcreateRoute(), register it withapp.openapi(), and it validates with Zod. This guide uses it.hono-openapiis a separate community library. It addsdescribeRoute()and its ownvalidator()to regular Hono routes, and supports several schema libraries.
Each one builds the spec from its own route declarations, so pick one per app and follow its docs. When you look for help online, check which library an example uses before you copy it.
Set up the project
The API and its docs live in one repository: the Hono app at the root, and the Blume site in docs-site/. Start with a package.json that pins every version:
{
"name": "acme-messages",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"openapi": "tsx scripts/export-openapi.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hono/node-server": "2.1.1",
"@hono/zod-openapi": "1.6.3",
"hono": "4.13.9",
"zod": "4.6.5"
},
"devDependencies": {
"@types/node": "24.19.0",
"tsx": "4.23.15",
"typescript": "7.0.2"
}
}{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src", "scripts"]
}Install the dependencies:
npm installBy the end of the guide, the repository looks like this:
acme-messages/
├── package.json
├── tsconfig.json
├── scripts/
│ └── export-openapi.ts
├── src/
│ ├── app.ts
│ ├── schemas.ts
│ └── server.ts
└── docs-site/ the Blume site
├── blume.config.ts
├── openapi.json written by npm run openapi
└── docs/Describe requests and responses with Zod
Import z from @hono/zod-openapi rather than from zod: it's the same Zod, with an .openapi() method added for documentation. Put the schemas in their own file:
import { z } from "@hono/zod-openapi";
export const MessageIdParams = z.object({
id: z
.string()
.regex(/^msg_[a-z0-9]+$/)
.openapi({
description: "The message ID, from the response to Send a message.",
example: "msg_8f2k",
}),
});
export const SendMessageRequest = z
.object({
channel: z.enum(["email", "sms"]).openapi({
description: "How to deliver the message.",
}),
to: z.string().min(3).openapi({
description:
"An email address for email, or a phone number in E.164 format for SMS.",
example: "ada@example.com",
}),
template: z.string().min(1).openapi({
description: "The ID of the template to send.",
example: "welcome",
}),
variables: z
.record(z.string(), z.string())
.optional()
.openapi({
description: "Values for the template's variables.",
example: { name: "Ada" },
}),
})
.refine((body) => body.channel !== "email" || body.to.includes("@"), {
message: "An email message needs an email address.",
path: ["to"],
})
.openapi("SendMessageRequest");
export const Message = z
.object({
id: z.string().openapi({ example: "msg_8f2k" }),
status: z.enum(["queued", "sent", "delivered", "failed"]),
channel: z.enum(["email", "sms"]),
})
.openapi("Message");
export const ErrorResponse = z
.object({
code: z.string().openapi({ example: "validation_failed" }),
message: z.string().openapi({ example: "The request is invalid." }),
issues: z
.array(
z.object({
path: z.string().openapi({ example: "to" }),
message: z.string(),
})
)
.optional()
.openapi({ description: "One entry per field that failed validation." }),
})
.openapi("Error");.openapi("Message") registers a schema as a named component, so the spec refers to it by name instead of repeating it on every route.
Not everything in a route definition does the same job. Some of it is enforced on every request, some of it only describes the API, and one kind of rule is enforced but never documented:
| What you write | Checked at runtime | In the spec |
|---|---|---|
Types, z.enum(), .min(), .regex(), .optional() | Yes, a failure is a 400 | Yes, as enum, minLength, pattern, required |
.refine() | Yes | No |
.openapi() descriptions, examples, and names | No | Yes |
A route's summary, description, tags, operationId | No | Yes |
responses schemas | No, but TypeScript checks your handlers against them | Yes |
| Security schemes | No, auth middleware enforces them | Yes |
The .refine() rule, that an email needs an email address, can't be expressed in OpenAPI, so it never reaches the spec. Say it in a field's description instead, as the to description does.
Define the routes
Each route declares its request schemas and every response it can send, errors included. The app's defaultHook formats validation failures so they match the Error schema the routes document:
import { OpenAPIHono, createRoute, type z } from "@hono/zod-openapi";
import { bearerAuth } from "hono/bearer-auth";
import {
ErrorResponse,
Message,
MessageIdParams,
SendMessageRequest,
} from "./schemas.ts";
export const app = new OpenAPIHono({
// Runs whenever a request fails a route's Zod schema, so every validation
// error has the shape the spec documents as a 400.
defaultHook: (result, c) => {
if (!result.success) {
return c.json(
{
code: "validation_failed",
message: "The request is invalid.",
issues: result.error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
})),
},
400
);
}
},
});
// Documents the scheme. The middleware below is what enforces it.
app.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
type: "http",
scheme: "bearer",
description: "An API key, sent as a bearer token.",
});
app.use(
"/messages/*",
bearerAuth({ verifyToken: (token) => token === process.env.ACME_API_KEY })
);
const json = <T extends z.ZodType>(schema: T) => ({
"application/json": { schema },
});
const sendMessage = createRoute({
method: "post",
path: "/messages",
operationId: "sendMessage",
tags: ["Messages"],
summary: "Send a message",
description:
"Queues an email or SMS for delivery and returns its ID right away.",
request: {
body: { required: true, content: json(SendMessageRequest) },
},
responses: {
202: { description: "The message is queued.", content: json(Message) },
400: {
description: "The body failed validation.",
content: json(ErrorResponse),
},
},
});
const getMessage = createRoute({
method: "get",
path: "/messages/{id}",
operationId: "getMessage",
tags: ["Messages"],
summary: "Get a message",
description: "Returns a message and its delivery status.",
request: { params: MessageIdParams },
responses: {
200: { description: "The message.", content: json(Message) },
400: {
description: "The ID isn't a message ID.",
content: json(ErrorResponse),
},
404: { description: "No message has that ID.", content: json(ErrorResponse) },
},
});
// Stands in for your database.
const messages = new Map<string, z.infer<typeof Message>>();
app.openapi(sendMessage, (c) => {
const body = c.req.valid("json");
const message = {
id: `msg_${crypto.randomUUID().slice(0, 8)}`,
status: "queued" as const,
channel: body.channel,
};
messages.set(message.id, message);
return c.json(message, 202);
});
app.openapi(getMessage, (c) => {
const { id } = c.req.valid("param");
const message = messages.get(id);
if (!message) {
return c.json(
{ code: "not_found", message: `No message has the ID ${id}.` },
404
);
}
return c.json(message, 200);
});
export const openapiConfig = {
openapi: "3.1.0",
info: {
title: "Acme Messages API",
version: "1.0.0",
description: "Send transactional email and SMS.",
},
servers: [{ url: "https://api.acme.example/v1" }],
security: [{ bearerAuth: [] }],
tags: [
{
name: "Messages",
description: "Send a message and check whether it arrived.",
},
],
};
app.doc31("/openapi.json", openapiConfig);A few of these fields decide how the docs turn out:
operationIdandtagsset each page's URL. Blume files an operation under its first tag and turns a camelCase ID into a slug, sosendMessagebecomes/api/messages/send-message.summaryis the page title and sidebar label, anddescriptionis the page's introduction.required: trueon the body makes a missing body a 400. Without it, a request with no body reaches your handler.securityinopenapiConfigapplies the bearer scheme to every operation, so each page shows an Authorization section. ThebearerAuthmiddleware is what rejects a request without the key.
@hono/zod-openapi doesn't check responses at runtime, but TypeScript does check each handler's c.json() against the route's responses. Return status: "sending", or a 200 where the route documents a 202, and npm run typecheck fails. Run it in CI so the documented success and error shapes can't drift from what the handlers return.
Run the API and trigger validation errors
Mount the app under /v1, matching the server URL in the spec, and allow the docs origin with CORS so the docs site's Try it panel can call the API from a browser:
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { app } from "./app.ts";
const server = new Hono()
// Lets the docs site's Try it panel call the API from the browser.
.use("*", cors({ origin: "https://docs.acme.example" }))
.route("/v1", app);
serve({ fetch: server.fetch, port: 3000 }, (info) => {
console.log(`Acme Messages API on http://localhost:${info.port}/v1`);
});Start it with an API key:
ACME_API_KEY=sk_test_123 npm run devIn another terminal, send a message with an unknown channel and a to that's too short:
curl http://localhost:3000/v1/messages \
-H "Authorization: Bearer sk_test_123" \
-H "Content-Type: application/json" \
-d '{"channel":"fax","to":"a","template":"welcome"}'Zod rejects both fields before your handler runs, and the hook answers with a 400 in the documented shape:
{"code":"validation_failed","message":"The request is invalid.","issues":[{"path":"channel","message":"Invalid option: expected one of \"email\"|\"sms\""},{"path":"to","message":"Too small: expected string to have >=3 characters"}]}Send {"channel":"email","to":"+15550100","template":"welcome"} and the .refine() rule fails instead: one issue on to, with the message from your schema. A valid request returns a 202 with the message, and a request without the Authorization header gets a 401 from bearerAuth.
Export the OpenAPI document
Create the Blume site first, since the export script writes into it. From the repository root:
npx blume init docs-site --template docs --yesThat scaffolds docs-site/ with its own package.json, a blume.config.ts, and a docs/index.mdx home page, then installs Blume. Next, add the export script:
import { writeFile } from "node:fs/promises";
import { app, openapiConfig } from "../src/app.ts";
const document = app.getOpenAPI31Document(openapiConfig);
await writeFile(
"docs-site/openapi.json",
`${JSON.stringify(document, null, 2)}\n`
);
console.log("Wrote docs-site/openapi.json");npm run openapigetOpenAPI31Document() builds the document from the registered routes without starting a server. It's the same document app.doc31() serves at /v1/openapi.json. Commit the file. Blume can read a spec from a URL, but a committed file shows every API change as a diff in review, and the docs build never needs the API running or its dependencies installed.
Build the docs site
Replace the scaffolded config. It mounts the reference at /api and adds a header tab for it, 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" })],
});The generated pages cover each route. The error format deserves a page of its own, written by hand. Put it in a folder named after the reference route and it joins the API reference tab's sidebar:
---
title: Errors
description: How the Acme Messages API reports validation failures and missing messages.
---
Every error is JSON with a `code` and a `message`. A request that fails
validation gets a `400` with the code `validation_failed`, and one entry
in `issues` for each field that failed:
```json
{
"code": "validation_failed",
"message": "The request is invalid.",
"issues": [
{ "path": "to", "message": "An email message needs an email address." }
]
}
```
Fix the fields the issues name and send the request again. Each field's
rules are on its operation's page, like [Send a message](/api/messages/send-message).
A message ID that doesn't exist gets a `404` with the code `not_found`.
A request without a valid API key gets a `401` with a plain-text body.Start the docs dev server:
cd docs-site
npm run devThe API reference tab now holds three pages:
| Page | Built from |
|---|---|
/api/messages/send-message | The sendMessage route |
/api/messages/get-message | The getMessage route |
/api/errors | Your errors.mdx |
/api itself is an overview with the API's version, server URL, and the Messages tag. Each operation page shows the Authorization section, the parameters and body with their enums, lengths, and patterns, and a Responses section with a schema table for every status you declared, the 400 and 404 included. The Try it panel starts from your example values. It sends requests to https://api.acme.example/v1, which doesn't exist, so point servers at your real API.
The OpenAPI guide covers the rest of the reference: code sample languages, the CORS proxy, and deploying.
Keep the docs in sync
The spec is regenerated from code, so a change to the docs starts with a change to a route or schema. Then run the checks and commit the spec with the code:
npm run typecheck
npm run openapi
git add src docs-site/openapi.jsonThe docs dev server doesn't watch the spec file, so restart it to see the change. To catch a route change that was committed without a fresh export, regenerate the spec in CI and fail when it differs from the committed file:
name: API spec
on:
pull_request:
push:
branches: [main]
jobs:
openapi:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
- run: npm run typecheck
- run: npm run openapi
- run: git diff --exit-code docs-site/openapi.jsongit diff --exit-code fails the job when the export changed the file, and prints the difference. For deeper checks, like flagging a breaking change, see Keep API documentation in sync with backend changes in CI.
Renaming an operationId or a route's first tag moves its page. Add a redirect from the old URL, as the OpenAPI guide shows.
Troubleshooting
Validation errors don't match the documented 400
Without a defaultHook or a per-route hook, a failed request gets the validator's default body, {"success":false,"error":{"name":"ZodError","message":"..."}}, where message is the list of issues as a JSON string. Your spec still documents whatever schema you declared for the 400, so the docs are wrong. Add the hook shown above. A hook passed as the third argument to app.openapi() overrides it for that route.
A POST with no body reaches the handler
A request body is optional unless it sets required: true. When it's optional and absent, c.req.valid("json") returns {}, even if the schema has required fields.
Requests fail with 415 Unsupported Media Type
A route that validates a JSON body rejects a body sent without a matching Content-Type header. Send Content-Type: application/json. A body declared with a media type other than JSON or form data isn't validated at all.
TypeScript doesn't catch a wrong response
Declaring a response with only a description and no content, like a bare 401, stops TypeScript from checking the handler's c.json() calls against the route. Give every declared response a content schema, or document that status on a hand-written page, as the errors page does for the 401.
A route is missing from the docs
Only routes registered with app.openapi() reach the spec. A route added with app.get() or app.post() works but isn't documented. The same goes for routes on a plain Hono sub-app: OpenAPIHono only collects routes from OpenAPIHono apps mounted directly on it. Also check the route doesn't set hide: true.
Pages land under Operations with URLs like post-messages
The route has no tags or operationId. Blume files untagged operations under an Operations group and builds the slug from the method and path, like /api/operations/post-messages. Set both on every createRoute().
The docs show an old schema
Run npm run openapi again and restart the docs dev server. If blume build fails with BLUME_OPENAPI_UNAVAILABLE, the spec file is missing or invalid: check that docs-site/openapi.json exists and that spec is ./openapi.json, relative to docs-site/.
Try it fails in the browser but curl works
The API has to allow the docs origin with CORS. Register cors() before bearerAuth, as server.ts does: the browser's preflight request carries no Authorization header, so if auth runs first the preflight gets a 401 and the real request is never sent. See Fix CORS errors in an API documentation playground for the proxy alternative.
Next step
Add a docs site to your API
Scaffold it beside your Hono app, export your spec into it, and mount it with openapi().
npx blume init docs-site --template docs --yesA step here not working for you? Report a broken step.