API reference
Generate API docs from an OpenAPI spec
Turn an OpenAPI file into a docs site with a page per operation, a request playground, and hand-written guides beside the reference.
By Hayden Bleasel8 min read

By the end of this guide, you have a docs site built from an OpenAPI spec. It has an overview page for the API, one page per operation with its parameters, schemas, and code samples, a Try it panel that sends real requests, and a hand-written authentication page in the same sidebar. Every page is static HTML you can host anywhere, with its own URL, search entry, and Markdown copy for agents.
The example is a fictional Acme Messages API that sends email and SMS. Swap in your own spec as you go: every step works the same way.
Create the project
Scaffold a new Blume project:
npx blume init acme-docsPick the docs template when it asks, and keep the default docs content folder. You get a docs/index.mdx home page, a blume.config.ts, and a package.json with dev and build scripts. There's also an api template that starts you on a sample pet store spec at /api, but starting from docs shows each piece as you add it.
Add your spec
Save the spec as openapi.yaml at the project root, beside blume.config.ts. A local file is the safer choice: Blume reads it at build time, so a build never depends on fetching the spec from somewhere else. JSON works too, and so do Swagger 2.0 and OpenAPI 3.0 specs, which Blume upgrades to 3.1 as it reads them.
openapi: 3.1.0
info:
title: Acme Messages API
version: 1.0.0
description: Send transactional email and SMS, and manage the templates they use.
servers:
- url: https://api.acme.example/v1
security:
- bearerAuth: []
tags:
- name: Messages
description: Send a message and check whether it arrived.
- name: Templates
description: Reusable message bodies with variables.
paths:
/messages:
post:
operationId: sendMessage
tags: [Messages]
summary: Send a message
description: Queues an email or SMS for delivery and returns its ID right away.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SendMessageRequest"
example:
channel: email
to: ada@example.com
template: welcome
responses:
"202":
description: The message is queued.
content:
application/json:
schema:
$ref: "#/components/schemas/Message"
/messages/{id}:
get:
operationId: getMessage
tags: [Messages]
summary: Get a message
description: Returns a message and its delivery status.
parameters:
- name: id
in: path
required: true
description: The message ID, from the response to Send a message.
schema:
type: string
example: msg_8f2k
responses:
"200":
description: The message.
content:
application/json:
schema:
$ref: "#/components/schemas/Message"
"404":
description: No message has that ID.
/templates:
get:
operationId: listTemplates
tags: [Templates]
summary: List templates
description: Returns every template in the workspace.
responses:
"200":
description: The templates.
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Template"
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: An API key, sent as a bearer token.
schemas:
SendMessageRequest:
type: object
required: [channel, to, template]
properties:
channel:
type: string
enum: [email, sms]
description: How to deliver the message.
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.
Message:
type: object
properties:
id:
type: string
example: msg_8f2k
status:
type: string
enum: [queued, sent, delivered, failed]
channel:
type: string
enum: [email, sms]
Template:
type: object
properties:
id:
type: string
example: welcome
name:
type: string
example: Welcome emailThe api.acme.example server doesn't exist. When you use your own spec, its servers list is where the Try it panel sends requests, so point it at your real API.
Mount the reference
Import the openapi() adapter from blume/reference and list it under reference. Then add header tabs, one for your guides and one for the reference, 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.yaml" })],
});route sets where the reference lives. Without it, the reference mounts at /reference. The reference never adds a header tab on its own, so the tab pointing at the same route is what makes it reachable, and it also scopes the sidebar to the reference's pages.
Start the dev server and open the site:
npm run devThe API reference tab opens an overview at /api with the API's version, its server URL, and a section per tag listing its operations. Each operation has its own page, at a URL built from its tag and operation ID:
| Operation | Page |
|---|---|
POST /messages | /api/messages/send-message |
GET /messages/{id} | /api/messages/get-message |
GET /templates | /api/templates/list-templates |
A camelCase operation ID like sendMessage is split into send-message. An operation with no ID takes its method and path instead, so give every operation an ID if you want URLs that read well.
Make the spec read well
Blume builds every page from what the spec says, so most improvements to your reference are improvements to the spec. Here's where each field shows up:
| In the spec | On the site |
|---|---|
info.title, info.description | The overview page's title and introduction |
A tag's description | The paragraph under that tag's section on the overview |
An operation's summary | The page title and sidebar label. Without one, the page is titled POST /messages. |
An operation's description | The page's introduction and its search result description |
| Parameter and schema descriptions | The parameter and schema tables |
example values | The examples, and the values the Try it form starts with |
security | An Authorization section, and a placeholder credential in every code sample |
Because the example spec sets security at the root, every operation shows an Authorization section, and its curl sample sends Authorization: Bearer YOUR_TOKEN. To mark one operation as public, give it security: [].
Add an authentication guide
A reference answers "what does this endpoint take?" Readers also need "how do I make my first call?", and that belongs in a page you write. Put it in a folder named after the reference route, and it joins the API reference tab's sidebar beside the generated pages:
---
title: Authentication
description: Send an API key as a bearer token with every request to the Acme Messages API.
---
Every request needs an API key, sent in the `Authorization` header as a
bearer token.
## Send your first 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 response holds the message ID. Pass it to
[Get a message](/api/messages/get-message) to check whether it arrived.The page lives at /api/authentication, and it links to an operation page by its URL like any other page. That's the whole trick for mixing guides and reference: they're all pages, so they share the sidebar, search, and links.
Try a request
Every operation page has a Try it panel, collapsed until a reader opens it. Its form comes from the operation: a field per parameter, a body editor built from the request schema, and an input for the bearer token. The code samples beside it update as the form changes, so a copied curl command always matches what Send would do.
Credentials typed into the panel stay in memory and are gone on reload, unless the reader checks Remember on this device. Code samples keep showing YOUR_TOKEN unless the reader turns on Include my values in samples, so a screenshot or copied snippet doesn't leak a key.
Why Try it fails in the browser
The panel sends requests straight from the reader's browser to your API. Browsers only allow that when the API answers with CORS headers that permit the docs site's origin. If your API doesn't, Send fails even though the same request works from curl.
You have two ways out. Add your docs origin to the API's CORS allowlist, or route requests through a proxy. Blume ships one: set playground.proxy to true. It runs as a server route, so it needs a host adapter from blume/deploy:
import { defineConfig } from "blume";
import { vercel } from "blume/deploy";
import { openapi } from "blume/reference";
export default defineConfig({
deployment: vercel(),
reference: [
openapi({
playground: { proxy: true },
route: "/api",
spec: "./openapi.yaml",
}),
],
});The proxy only forwards requests to the servers your spec declares, and only the headers the panel sets, so it can't be aimed at other hosts. If your API already allows the docs origin, skip it and keep a static build.
Choose your code samples
Each operation shows samples in curl, JavaScript, and Python by default. Pick your own languages, in the order you want them:
openapi({
codeSamples: ["curl", "typescript", "python", "go"],
route: "/api",
spec: "./openapi.yaml",
}),If you publish an SDK, show it instead of raw HTTP: add x-codeSamples to an operation in the spec, each with a lang, a source, and an optional label. Those tabs come first, ahead of the generated ones.
Build and check
Build the site and serve the production build locally:
npm run build
npx blume previewOpen the URL it prints and check three things:
- The operation URL.
/api/messages/send-messageloads on its own, not only from the sidebar. - Search. Searching for "send a message" or
POST /messagesfinds the operation. - The Markdown copy.
/api/messages/send-message.mdreturns the page as Markdown, with the endpoint written out asPOST /messages. That's what agents read, and it's listed in your site'sllms.txttoo.
Search matches an operation's summary, description, tag, and endpoint. It doesn't index every field in the schema tables or the code samples, so if readers search for a field name, mention it in the operation's description.
Run npx blume validate as well. It checks every internal link, including the links from your guides to operation pages.
Deploy
Without the proxy, the whole site is static files in dist/, so any static host works. On Vercel and Netlify, Blume detects the site's production URL. Cloudflare Pages only exposes each deploy's own URL, which changes every time, and other hosts expose none, so there, set deployment.site to your docs URL so the sitemap, canonical links, and Open Graph images are correct. For a step-by-step static deploy, see Deploy Markdown docs to GitHub Pages. With the proxy on, deploy to the host your adapter names; the deployment docs cover each one.
When the spec changes
The reference is rebuilt from the spec on every build, so publishing an updated spec is a matter of committing it and deploying. The dev server watches your content folder and blume.config.ts, not a spec file beside them, so restart it after you edit the spec.
One change needs care. An operation's URL comes from its tag and operation ID, so renaming either one moves the page. Add a redirect for the old URL so links and search results keep working:
redirects: [
{ from: "/api/messages/send-message", to: "/api/messages/create-message" },
],If the spec comes from your code or another team, and you want to trim or reword it for the docs without editing it, remove internal endpoints with an overlay instead.
Troubleshooting
The build fails with BLUME_OPENAPI_UNAVAILABLE
Blume couldn't read or parse the spec. The message says why: a wrong path, a failed download, or invalid YAML. In blume dev it's a warning and the reference is skipped, so a working dev server can still hide it. Check the path is relative to the project root, like ./openapi.yaml.
The reference is empty
BLUME_OPENAPI_EMPTY means the file parsed but declares no operations under paths. Check that spec points at the OpenAPI document itself, not another YAML file.
There's no API tab
The reference doesn't add a tab. Add one to navigation.tabs whose path matches the adapter's route exactly.
Send fails but curl works
That's CORS. Allow the docs origin in your API, or turn on the proxy as shown above. With the proxy on, a Custom base URL typed into the panel is refused with a 403, because it isn't one of the spec's servers. Fix CORS errors in an API documentation playground walks through reading the failed preflight.
A page's URL changed
You renamed an operation ID or moved an operation to another tag. Add a redirect from the old URL, and run npx blume validate to find internal links that still point at it.
Next step
Start your API docs
Start a project, drop your spec beside it, and add the reference to your config.
npx blume initA step here not working for you? Report a broken step.