API reference
Generate a Fastify documentation site from route schemas
Turn the JSON Schemas on your Fastify routes into an OpenAPI 3.1 file and a docs site with a page per route, a Try it panel, and guides beside the reference.
By Hayden Bleasel12 min read

To generate a docs site from Fastify route schemas, add the @fastify/swagger plugin. Fastify already asks you to describe each route with JSON Schema, for validation and fast serialization, and the plugin reads those schemas and turns them into an OpenAPI document. Write that document to a file with a short script, point Blume at it, and you get a docs site with a page per route, a Try it panel, and your own guides in the same sidebar.
By the end, you have a Fastify app for a fictional Acme Messages API. Its shared schemas become named components with examples, and its bearer auth shows on every operation. A script exports its spec into a Blume project that also holds a quickstart and an errors page. All the code is on this page.
If you only want an interactive explorer served by the API itself, @fastify/swagger-ui or Scalar's Fastify plugin does that without a second project. Blume fits when you want a public docs site where guides and reference share navigation, search, and a deploy. The example uses plain JSON Schema. TypeBox schemas are JSON Schema too, so they work the same way. With Zod through fastify-type-provider-zod, pass its jsonSchemaTransform as the plugin's transform option; everything after that is the same. For the spec-first version of this walkthrough, see Generate API docs from an OpenAPI spec.
Set up both projects
The API and its docs live in one repository as two packages: the Fastify app at the root and a Blume project in docs-site/. Keeping them apart keeps Blume's dependencies out of the API's install, and lets your docs host build the docs without the API.
acme-messages-api/
package.json
scripts/
export-openapi.js
src/
app.js
schemas.js
server.js
routes/
messages.js
docs-site/
blume.config.ts
openapi.json
package.json
docs/Start with the API's package.json:
{
"name": "acme-messages-api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.js",
"openapi": "node scripts/export-openapi.js"
},
"dependencies": {
"@fastify/swagger": "9.9.0",
"fastify": "5.12.5"
}
}Install it, then scaffold the docs site beside the code:
npm install
npx blume init docs-site --template docs --yesYou get docs-site/blume.config.ts, a docs/index.mdx home page, and a package.json with dev and build scripts, and init installs Blume there.
Register Fastify Swagger before your routes
app.js builds the app without starting it, so the server and the export script can share it. The order of the three steps matters:
import swagger from "@fastify/swagger";
import Fastify from "fastify";
import messageRoutes from "./routes/messages.js";
import { schemas } from "./schemas.js";
export async function buildApp(options = {}) {
const app = Fastify(options);
// 1. Register @fastify/swagger first: it only sees routes added after it.
await app.register(swagger, {
openapi: {
openapi: "3.1.0",
info: {
title: "Acme Messages API",
version: "1.0.0",
description:
"Send transactional email and SMS, and check whether they arrived.",
},
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: [] }],
},
// Keep each shared schema's $id as its component name.
refResolver: {
buildLocalReference(json, baseUri, fragment, i) {
return json.$id || `def-${i}`;
},
},
});
// 2. Shared schemas, before the routes that reference them.
for (const schema of schemas) {
app.addSchema(schema);
}
// 3. Routes. A hidden health check stays out of the spec.
app.get("/health", { schema: { hide: true } }, async () => ({ ok: true }));
await app.register(messageRoutes, { prefix: "/v1" });
return app;
}@fastify/swagger collects routes with an onRoute hook, so it only sees routes added after it. Routes added earlier are skipped without an error, and the spec comes out with empty paths. Await the register call before any app.get() or route plugin, and register it on the root instance. Registered inside another plugin, its swagger() method doesn't exist on the root app.
components.securitySchemes defines the bearer scheme, and the root security applies it to every route. The plugin only documents auth. The hook in the routes plugin below is what enforces it.
The routes mount under /v1, and the server URL ends in /v1. The plugin strips that base path from each route (its stripBasePath option, on by default), so the spec lists /messages and Try it calls https://api.acme.example/v1/messages. With no server URL ending in the prefix, paths keep it: /v1/messages.
Choose OpenAPI 3.1
@fastify/swagger writes Swagger 2.0 unless you pass the openapi option, and OpenAPI 3.0.3 unless you set a version inside it. Set "3.1.0". Blume's reference is built on OpenAPI 3.1, so a 3.1 document is read as written, and JSON Schema's null types stay as they are instead of being rewritten to 3.0's nullable. A 3.0.3 document works too, because Blume upgrades Swagger 2.0 and OpenAPI 3.0 as it reads them.
Don't pick "3.2.0", even though the plugin supports it. Blume doesn't convert 3.2 documents and renders only what 3.1 defines, so QUERY routes would be missing and nested tags would come out flat.
Define shared schemas and examples
Schemas that more than one route uses go in one file, each with an $id:
export const schemas = [
{
$id: "Channel",
type: "string",
enum: ["email", "sms"],
description: "How to deliver the message.",
},
{
$id: "SendMessageRequest",
type: "object",
required: ["channel", "to", "template"],
additionalProperties: false,
properties: {
channel: { $ref: "Channel#" },
to: {
type: "string",
description: "An email address, or a phone number in E.164 format.",
},
template: {
type: "string",
description: "The ID of the template to send.",
},
variables: {
type: "object",
additionalProperties: { type: "string" },
description: "Values for the template's variables.",
},
},
examples: [
{
channel: "email",
to: "ada@example.com",
template: "welcome",
variables: { name: "Ada" },
},
],
},
{
$id: "Message",
type: "object",
required: ["id", "status", "channel"],
properties: {
id: { type: "string", description: "The message ID." },
status: {
type: "string",
enum: ["queued", "sent", "delivered", "failed"],
},
channel: { $ref: "Channel#" },
},
examples: [{ id: "msg_8f2k", status: "queued", channel: "email" }],
},
{
$id: "Error",
type: "object",
required: ["statusCode", "error", "message"],
properties: {
statusCode: { type: "integer" },
code: { type: "string" },
error: { type: "string" },
message: { type: "string" },
},
},
];A route refers to a shared schema by its $id, as in { $ref: "Message#" }, and that one reference drives Fastify's validation, its response serialization, and the spec. By default, the plugin renames shared schemas to def-0, def-1, and so on. Blume labels a referenced type by its component name, so the schema tables would say def-2 where you want Message. The refResolver in app.js keeps each $id as the component name.
examples is a standard JSON Schema keyword, so Fastify's validator accepts it. The plugin turns the first entry into the component's example, and Blume uses it to prefill the Try it request body and to show the response example. Keep examples on the shared schema: next to a $ref in a route, the plugin drops them. Inline route schemas can carry their own examples.
Describe each route
The routes plugin holds the handlers, the auth hook, and the fields that turn into page titles, descriptions, and URLs:
import { randomUUID } from "node:crypto";
const messages = new Map();
export default async function messageRoutes(fastify) {
fastify.addHook("onRequest", async (request, reply) => {
const key = process.env.ACME_API_KEY;
if (!key || request.headers.authorization !== `Bearer ${key}`) {
return reply.code(401).send({
statusCode: 401,
error: "Unauthorized",
message: "Send a valid API key as a bearer token.",
});
}
});
fastify.post(
"/messages",
{
schema: {
operationId: "sendMessage",
tags: ["Messages"],
summary: "Send a message",
description:
"Queues an email or SMS for delivery and returns its ID right away.",
body: { $ref: "SendMessageRequest#" },
response: {
202: {
description: "The message is queued.",
$ref: "Message#",
},
400: { description: "The body failed validation.", $ref: "Error#" },
401: { description: "The API key is missing or wrong.", $ref: "Error#" },
},
},
},
async (request, reply) => {
const message = {
id: `msg_${randomUUID().slice(0, 8)}`,
status: "queued",
channel: request.body.channel,
};
messages.set(message.id, message);
return reply.code(202).send(message);
}
);
fastify.get(
"/messages/:id",
{
schema: {
operationId: "getMessage",
tags: ["Messages"],
summary: "Get a message",
description: "Returns a message and its delivery status.",
params: {
type: "object",
required: ["id"],
properties: {
id: {
type: "string",
description: "The message ID, from the response to Send a message.",
},
},
},
response: {
200: { description: "The message.", $ref: "Message#" },
401: { description: "The API key is missing or wrong.", $ref: "Error#" },
404: { description: "No message has that ID.", $ref: "Error#" },
},
},
},
async (request, reply) => {
const message = messages.get(request.params.id);
if (!message) {
return reply.code(404).send({
statusCode: 404,
error: "Not Found",
message: `No message has the ID ${request.params.id}.`,
});
}
return message;
}
);
}The hook compares the header with an ACME_API_KEY environment variable so the example runs on its own. Swap in your real auth, such as @fastify/bearer-auth. Here's where each schema field ends up:
| In the route schema | On the docs site |
|---|---|
operationId | The page URL: sendMessage becomes send-message |
tags | The sidebar group, and the URL segment before the operation |
summary | The page title and sidebar label |
description | The page's introduction and its search result description |
params, body, response | The parameter, request body, and response tables |
A response's description | The text beside that status code |
To leave a route out of the docs, add hide: true to its schema, like the health check in app.js. To mark one route public in an API that needs auth everywhere else, give its schema security: []. Blume then shows no Authorization section on that page.
Export the spec to a file
The server and the export script both start from buildApp():
import { buildApp } from "./app.js";
const app = await buildApp({ logger: true });
await app.listen({ port: Number(process.env.PORT ?? 3000), host: "0.0.0.0" });import { writeFile } from "node:fs/promises";
import { buildApp } from "../src/app.js";
const app = await buildApp();
await app.ready();
const spec = app.swagger();
await writeFile(
new URL("../docs-site/openapi.json", import.meta.url),
`${JSON.stringify(spec, null, 2)}\n`
);
await app.close();
console.log(`Wrote ${Object.keys(spec.paths).length} paths to docs-site/openapi.json`);Run it:
npm run openapiIt prints Wrote 2 paths to docs-site/openapi.json. The script never listens on a port. It waits for ready(), because the plugin can't build the spec before every route is loaded, then writes app.swagger() as JSON. Pass { yaml: true } to swagger() if you'd rather commit YAML.
A file is a better input than a running server's spec URL: the docs build doesn't depend on the API being up, and every spec change shows up in code review as a diff. One catch: ready() loads every plugin, so anything a plugin does at startup, like opening a database connection, happens during the export too. If that's a problem, give buildApp() an option that skips those plugins.
Mount the spec in Blume
Point an openapi() reference at the exported file, and add header tabs so readers can reach it:
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" })],
});Start the dev server from the docs project:
cd docs-site
npm run devThe API reference tab opens an overview at /api with the API's description, version, and server URL, and a section for the Messages tag. Each route gets its own page:
| Fastify route | Page |
|---|---|
POST /v1/messages | /api/messages/send-message |
GET /v1/messages/:id | /api/messages/get-message |
Both pages show an Authorization section and send Authorization: Bearer YOUR_TOKEN in their code samples, because of the root security. The schema tables label types Message and Channel, and the examples come from the shared schemas. For code sample languages and the rest of the reference's options, see the OpenAPI reference docs.
Write the usage guides
Route schemas answer "what does this endpoint take?" Readers also need "how do I send my first message?" and "what does an error look like?" Write those as pages in docs-site/docs/api/, a folder named after the reference route, and they join the API reference tab's sidebar beside the generated pages:
---
title: Quickstart
description: Send your first email with the Acme Messages API, then check whether it was delivered.
---
Every request needs an API key, sent in the `Authorization` header as a
bearer token.
## Send a message
```bash
curl https://api.acme.example/v1/messages \
-H "Authorization: Bearer $ACME_API_KEY" \
-H "Content-Type: application/json" \
-d '{"channel":"email","to":"ada@example.com","template":"welcome"}'
```
The API answers `202` with the queued message:
```json
{ "id": "msg_8f2k", "status": "queued", "channel": "email" }
```
## Check whether it arrived
Pass the ID to [Get a message](/api/messages/get-message). Its `status`
moves from `queued` to `sent` to `delivered`, or to `failed`.The errors page documents the shape Fastify gives every error. When a body fails validation, Fastify answers with a 400 and its own FST_ERR_VALIDATION code, which is why the shared Error schema has an optional code:
---
title: Errors
description: Every Acme Messages API error has the same JSON shape, with the HTTP status and a message that says what to fix.
---
Errors come back as JSON:
```json
{
"statusCode": 400,
"error": "Bad Request",
"message": "body must have required property 'template'",
"code": "FST_ERR_VALIDATION"
}
```
| Status | Meaning |
| --- | --- |
| 400 | The request body failed validation. `message` names the field. |
| 401 | The API key is missing or wrong. |
| 404 | No message has that ID. |
See [Send a message](/api/messages/send-message) for the fields each request takes.The pages live at /api/quickstart and /api/errors, and they link to operation pages by URL like any other page. Run npx blume validate in docs-site to check those links.
Let Try it reach your API
The Try it panel on each operation page sends requests from the reader's browser straight to the server in your spec. That only works when the API allows the docs site's origin with CORS. In Fastify, that's @fastify/cors:
npm install @fastify/cors@11.3.0import cors from "@fastify/cors";
// Inside buildApp(), after the shared schemas and before the routes:
await app.register(cors, { origin: ["https://docs.acme.example"] });The plugin answers preflight requests with its own OPTIONS route. That route is hidden from the spec by default (its hideOptionsRoute option), and it sits outside the messages plugin, so the auth hook never rejects a preflight. By default it allows GET, HEAD, and POST. If your routes use PUT, PATCH, or DELETE, list them in its methods option. If you can't change the API's CORS policy, Blume can proxy the requests instead; see Fix CORS errors in an API documentation playground.
Keep the spec and the site in sync
Commit docs-site/openapi.json. Your docs host then builds docs-site on its own, with npm run build into static files in dist/, and never installs or starts the API. The deployment docs cover each host.
The Blume dev server doesn't watch the spec file. After npm run openapi, restart npm run dev to see the new routes.
To catch a route change that nobody re-exported, run the export in CI and fail when the committed file differs:
npm run openapi
git diff --exit-code docs-site/openapi.jsonKeep API documentation in sync with backend changes in CI covers the full workflow. Renaming an operationId or moving a route to another tag changes its page URL, so add a redirect from the old one when you do.
Troubleshooting
The reference is empty
Blume warns with BLUME_OPENAPI_EMPTY when the spec has no operations. With Fastify, that usually means @fastify/swagger was registered after the routes, so paths in openapi.json is empty. Move the awaited register call above every route and route plugin, then export again.
The export fails with ".swagger() must be called after .ready()"
The plugin resolves shared schemas when the app is ready. Call await app.ready() before app.swagger(), as the export script does.
The export fails with "app.swagger is not a function"
@fastify/swagger was registered inside another plugin, so its decorator isn't on the root app. Register it directly on the instance that buildApp() returns.
The export fails with "Cannot read properties of undefined"
A security requirement, at the root or on a route, names a scheme that isn't in components.securitySchemes. The plugin looks each one up while building the spec and crashes on a missing one. Check that the names match exactly, like bearerAuth in both places.
Schema tables show def-0 instead of a name
The plugin's default refResolver names shared schemas def-0, def-1, and so on. Add the buildLocalReference function from app.js so each component keeps its $id.
The build fails with BLUME_OPENAPI_UNAVAILABLE
Blume couldn't read openapi.json. Usually the export never ran on that machine, or the file isn't committed. Check that spec is relative to docs-site, like ./openapi.json. In blume dev this is only a warning, so a working dev server can hide it.
Next step
Scaffold the docs site
Run it at the root of your Fastify repository, then export the spec into it with the script from this guide.
npx blume init docs-site --template docs --yesA step here not working for you? Report a broken step.