Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

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 11 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-openapi is maintained in Hono's middleware repository. You define a route with createRoute(), register it with app.openapi(), and it validates with Zod. This guide uses it.
  • hono-openapi is a separate community library. It adds describeRoute() and its own validator() 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 install

By 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 writeChecked at runtimeIn the spec
Types, z.enum(), .min(), .regex(), .optional()Yes, a failure is a 400Yes, as enum, minLength, pattern, required
.refine()YesNo
.openapi() descriptions, examples, and namesNoYes
A route's summary, description, tags, operationIdNoYes
responses schemasNo, but TypeScript checks your handlers against themYes
Security schemesNo, auth middleware enforces themYes

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:

  • operationId and tags set each page's URL. Blume files an operation under its first tag and turns a camelCase ID into a slug, so sendMessage becomes /api/messages/send-message.
  • summary is the page title and sidebar label, and description is the page's introduction.
  • required: true on the body makes a missing body a 400. Without it, a request with no body reaches your handler.
  • security in openapiConfig applies the bearer scheme to every operation, so each page shows an Authorization section. The bearerAuth middleware 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 dev

In 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 --yes

That 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 openapi

getOpenAPI31Document() 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 dev

The API reference tab now holds three pages:

PageBuilt from
/api/messages/send-messageThe sendMessage route
/api/messages/get-messageThe getMessage route
/api/errorsYour 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.json

The 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.json

git 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 --yes
Check the spec in CI

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init