OpenAPI
Drop in an OpenAPI spec and get a native API reference — one real page per operation, in your sidebar and search.
Point Blume at an OpenAPI spec and it generates a native API reference: one real page per operation, grouped by tag in a tab-scoped sidebar, with schema tables, request/response examples, generated code samples, and an interactive Try it panel. Because each operation is a genuine Blume page, it gets its own URL, shows up in site search and llms.txt, and gets an Open Graph image — the same as any hand-written doc.
Every reference is an adapter imported from blume/reference and listed under reference: openapi() for an OpenAPI document, asyncapi() for an AsyncAPI document, and graphql() for a GraphQL schema. Each adapter owns its spec sources, its mount route, and its display options, so the list can hold as many of each kind as you need. The config below points Blume at the public Petstore spec as an example.
import { defineConfig } from "blume";
import { openapi } from "blume/reference";
export default defineConfig({
reference: [
openapi({ spec: "https://petstore3.swagger.io/api/v3/openapi.json" }),
],
});
That mounts the reference at /reference (an overview page) with each operation at /reference/<tag>/<operation>. The spec is either an http(s) URL or a path to a local file in your project. Blume parses it with Scalar’s OpenAPI parser — Swagger 2.0 and OpenAPI 3.0 specs are upgraded to 3.1 automatically, and 3.1 and 3.2 specs are read as written. An adapter is a plain description of the reference — not a parsed spec — so Blume can validate it up front and inline it into the generated site; leaving reference out (or empty) renders no reference at all. Documenting an event-driven or GraphQL API instead? See AsyncAPI and GraphQL.
To go from your framework’s generated spec to a deployed reference, follow the guide for FastAPI, Django REST Framework, NestJS, Spring Boot, ASP.NET Core, Laravel, Rails, Hono, Fastify, Elysia, or oRPC. If you define the API in TypeSpec, publish it from the TypeSpec definition. Generate API docs from an OpenAPI spec starts from any spec file.
The reference doesn’t add a header tab on its own. To surface it, point a navigation tab at its route — this also scopes the operations sidebar for the native renderer:
navigation: {
tabs: [{ label: "API", path: "/reference" }],
}
The sidebar’s tag groups and the overview page’s sections follow the same order: the order of the spec’s top-level tags list, then any tag it doesn’t declare, in the order operations first use it. Within a tag, operations follow the spec too: paths in the order they’re listed, and each path’s methods in the order they’re written. A meta.ts in a tag’s folder with its own pages list overrides that order.
A tag’s x-displayName, Redocly’s extension, labels its sidebar group and overview section in place of its name. Its URL still comes from its name, so adding one doesn’t move any page. An operation is labeled by its summary, or by its method and path (GET /pets), as its page is titled, when it has none.
The spec’s info.description opens the overview page, and each tag’s description opens its section there. Their headings move one level under where they sit, so the page’s title stays its only top-level heading: a # Authentication in info.description or an operation’s description renders as a second-level heading, and in a tag’s description as a third-level one, under the tag’s own.
A local spec
A relative path is resolved from your project root and read at build time. Both JSON and YAML work, and blume dev watches the file, along with any local overlays, so the reference updates when you save the spec:
reference: [openapi({ spec: "./openapi.yaml" })],
OpenAPI 3.2
OpenAPI 3.2 specs render as written. A query operation, for the HTTP QUERY method (a safe request that carries a body), gets a page, Try it, and code samples like any other method. Fields 3.2 adds that Blume doesn’t show, such as a tag’s or a response’s summary, are ignored rather than rejected. Some 3.2 additions don’t render yet:
additionalOperations, for methods a path item doesn’t name directly (likeCOPY): those operations are left out, with aBLUME_OPENAPI_UNSUPPORTEDwarning.in: querystringparameters: they get no row in the parameter table, no Try it input, and no place in the code samples, with the same warning.itemSchema, the schema of each item in a streamed body (text/event-stream,application/jsonl): the page shows no schema for that body unlessschemais set beside it.- Examples written with
dataValueorserializedValuein place ofvalue: the page doesn’t show them. - Nested tags (
parent) and tagkind: tags stay one flat list in the sidebar and the overview.
Route
route controls where the reference mounts — the overview page and the prefix for every operation route (and the route you point a navigation tab at):
reference: [
openapi({
route: "/api", // overview at /api, operations at /api/<tag>/<operation>
spec: "./openapi.yaml",
}),
],
An operation’s <tag> is its first tag (operations when it has none), and its <operation> is its operationId, else its method and path. Both are lowercased with their words split by hyphens, camelCase and PascalCase included: the tag InboxesThreads gives inboxes-threads, and the operationId listThreads gives list-threads. An operation’s slug only has to be unique within its tag, so two tags can each have a list; a second list in the same tag gets its method added (list-post), with a BLUME_OPENAPI_DUPLICATE_OPERATION_ID warning when the two share an operationId.
Earlier versions of Blume kept a tag’s words together (inboxesthreads) and made an operation’s slug unique across the whole spec (list-get for the second list). Blume redirects each of those URLs, and its Markdown copy, to the page’s current one, unless a page or one of your redirects is already at that URL.
Code samples and schemas
codeSamples picks which languages render per operation, in the order you list them. It defaults to ["curl", "js", "python"], and false or an empty list shows none. expandSchemas starts nested schema rows expanded rather than collapsed:
reference: [
openapi({
spec: "./openapi.yaml",
codeSamples: ["curl", "python", "go", "java", "csharp"],
expandSchemas: true,
}),
],
Every sample is plain, copy-pasteable code for the request, written with the language’s standard or most common HTTP client:
| Id | Language | Client | Also accepts |
|---|---|---|---|
curl |
cURL | curl |
bash, sh, shell |
python |
Python | requests |
py |
js |
JavaScript | fetch |
javascript |
node |
Node.js | axios |
nodejs, node.js |
typescript |
TypeScript | fetch |
ts |
php |
PHP | the curl extension | |
go |
Go | net/http |
golang |
java |
Java | java.net.http |
|
ruby |
Ruby | net/http |
rb |
powershell |
PowerShell | Invoke-RestMethod |
ps1 |
swift |
Swift | URLSession |
|
csharp |
C# | HttpClient |
c#, cs |
dotnet |
.NET | RestSharp | .net, dot-net |
c |
C | libcurl | |
cpp |
C++ | cpr | c++, cplusplus |
kotlin |
Kotlin | OkHttp | kt |
rust |
Rust | reqwest | rs |
dart |
Dart | package:http |
flutter |
Each value is quoted by its own language’s rules, so a header like If-Match: "33a64df5" or a body with a $ in it reaches the API as written.
An id Blume doesn’t generate a sample for, such as ReadMe’s objectivec, is left out of every page, with a BLUME_OPENAPI_UNKNOWN_CODE_SAMPLE warning that lists the ids above. To show a sample in another language, write it into the spec as your own sample.
Schema tables follow the way the data travels, as the examples do: a request body leaves out readOnly properties, which the server sets, and a response leaves out writeOnly ones, which it never returns. A webhook or callback body is a request your API sends, so it reads like a response. The same goes for the request samples, the Try it form and its prefill, and the response examples, including examples written into the spec, such as a model-level example that holds the model’s id.
A body or response whose examples map names two or more examples shows each one, labeled by its summary, or by its key when it has none. A response gets a tab per example after its status (200 · A cat), a webhook payload one per example after its media type, and a request body an Examples block under its schema. The code samples and Try it start from the first.
A property written as a $ref with keywords beside it, which OpenAPI 3.1 allows, reads those keywords over the referenced schema’s. Its own description is the one its row shows, and its own example or default is the one the samples use.
Your own samples
To show your SDK instead of a raw HTTP call, add x-codeSamples to an operation in the spec. It’s the extension Redocly defined, and Speakeasy and Stainless can write it for you. Each entry takes a lang, the source, and an optional label:
paths:
/plants:
get:
x-codeSamples:
- lang: typescript
label: SDK
source: |
const plants = await planter.plants.list();
- lang: bash
label: CLI
source: |
planter list
These samples render as their own tabs, ahead of the generated ones, and stay as written when readers change the Try it form. The older x-code-samples spelling works too. To show only your own samples, set codeSamples: false. Unlike the generated samples, they’re also in the operation page’s Markdown copy, so llms-full.txt, the MCP server, and the assistant see your SDK calls. That copy also lists the request body’s media type and top-level properties with its example (every named one), and each response’s status and description, with the examples the spec records and a sampled one for the first success response that records none.
A source can also be a $ref to a file that holds the sample’s code, the way Redocly describes keeping samples in files of their own. Blume reads the file relative to the spec, fetching it when the spec is a URL. A $ref it can’t read, or one that points inside a document (#/…), leaves that sample out with a BLUME_OPENAPI_CODE_SAMPLE_REF warning:
paths:
/plants:
get:
x-codeSamples:
- lang: go
source:
$ref: ./code_samples/go/list_plants.go
Add SDK examples to generated API documentation walks through samples for a TypeScript and a Python SDK, with a check that catches samples an SDK release broke.
Try it playground
Operation pages rendered natively ship an interactive Try it panel by default. Blume generates the form from the operation itself: an input per path, query, and header parameter, a body editor built from the request-body schema, everything prefilled from the spec’s examples. Values go on the wire the way the spec describes them: array and object parameters follow their style and explode (tags=dog&tags=cat in a query, 1,2 in a path, filter[color]=red for deepObject), and an application/x-www-form-urlencoded or multipart/form-data body is sent as form fields rather than JSON. A server picker lists the operation’s servers (its own servers, else its path’s, else the spec’s, with each {variable} at its default), with a free-text field for any other base URL, and auth inputs match the operation’s resolved security — bearer token, API key, and basic credentials, with OAuth2 as a token paste field (bring an access token; Blume doesn’t run the flow).
When any of the operation’s responses is JSON, requests ask for it: Send and the code samples carry an Accept header with the first JSON media type the responses list, a success response’s ahead of the others. Frameworks like Laravel answer a request without one as if a browser sent it, redirecting an unauthenticated call to a login page instead of returning the 401 the API documents. An Accept header parameter the operation declares takes its place.
The panel and the code samples stay in lockstep: values typed into the form update the generated samples live, so a copied curl command always matches exactly what Send would do. And it stays out of the way — the panel is server-rendered collapsed, and its JavaScript loads only when a reader first opens it. Readers who never touch it download none of it.
The one method a browser can’t send is TRACE: fetch refuses it, so on a trace operation Send says so instead of sending, and the JavaScript sample (built on fetch) is a note rather than code. The cURL and Python samples send it fine.
playground: false is the entire off switch:
reference: [openapi({ spec: "./openapi.yaml", playground: false })],
Credentials
Credentials typed into the auth inputs stay in memory and vanish on reload. Checking Remember on this device persists them in localStorage, scoped to the docs origin — they’re never sent anywhere except the API being called. Code samples keep showing placeholders (YOUR_TOKEN and friends) whatever’s typed, unless the reader toggles Include my values in samples.
CORS and the proxy
As with a Scalar embed, requests go directly from the browser to the target API, so the API must allow cross-origin requests from the docs site (Access-Control-Allow-Origin). For APIs that can’t, set playground.proxy: a URL routes requests through a proxy you host, and true enables the built-in /_api-proxy route (at {basePath}/_api-proxy when you set a basePath) — which needs server output: a host adapter such as deployment: vercel() from blume/deploy:
reference: [
openapi({
spec: "./openapi.yaml",
playground: {
proxy: true, // or a URL of your own
},
}),
],
The built-in proxy only forwards requests to the origins your specs declare in servers (at the document, path, or operation level, with variables at their defaults) — including across redirects — so a public docs deployment can’t be aimed at other hosts on its network. A Custom base URL typed into the panel isn’t a documented server: with the proxy enabled, requests to it are refused with a 403. It forwards only the headers the panel sets itself — the credentials and header parameters filled in, the Accept header, and the body’s Content-Type — so cookies, credentials the browser attaches for the docs site (HTTP Basic auth on a password-protected preview, say), and headers your host adds (X-Forwarded-For, CF-*, X-Vercel-*) never reach the API. It reads a request body only up to 4 MB (anything larger gets a 413), and every response it relays carries Content-Security-Policy: sandbox, X-Content-Type-Options: nosniff, and Cross-Origin-Resource-Policy: same-origin — plus Content-Disposition: attachment for HTML or SVG — so an API error page that echoes its input can’t run script on the docs origin. The proxy is also rate limited per reader, like every server route a reader can call.
When a Try it request is blocked, Fix CORS errors in an API documentation playground reproduces it, reads the preflight the browser sends, and fixes it with a CORS policy on your API or the built-in proxy.
Multiple specs
Use sources to publish more than one spec from one adapter. Each source gets its own overview route and operation pages, and shares the adapter’s display options. Give each a label, which names its group in the sidebar as written and derives its route, or set an explicit route:
reference: [
openapi({
sources: [
{ label: "Public API", spec: "./public.json" }, // → /reference/public-api
{ label: "Admin API", route: "/admin", spec: "./admin.json" },
],
}),
],
A source’s group sits at its route, and each folder of the route above it is a sidebar group too. So route a spec at the folder it describes: route: "/apis/messaging" makes the source’s group the messaging folder’s own group, named by its label, while "/apis/messaging/openapi" nests that group inside a Messaging group that holds nothing else.
spec is shorthand for a single-entry sources, so you only reach for sources when you have more than one. When two specs need different display options — a different code-sample set, say — list two openapi() adapters instead, each with its own route. An embedded Scalar reference beside native pages is its own scalar() adapter in the list. Sources are resolved in list order, and when two resolve to the same route the first one wins (the build warns about the one it dropped).
Combine multiple OpenAPI specifications in one docs site builds an API portal this way, with each service’s spec on its own route and tab.
Per-source indexing
Generated pages participate in search, llms.txt, and crawler indexing by default. A secondary or overlapping spec can opt out of any surface without hiding its pages or removing it from navigation:
reference: [
openapi({
sources: [
{ label: "Public API", route: "/api", spec: "./public.json" },
{
label: "Platform API",
route: "/platform",
spec: "./platform.json",
includeInSearch: false,
includeInLlms: false,
noindex: true,
},
],
}),
],
includeInSearch: falsekeeps the source’s overview and operations out of site search.includeInLlms: falsekeeps them out of bothllms.txtfiles.noindex: trueadds crawler noindex metadata and removes the pages from the sitemap.
Each operation page’s meta description is the operation’s own description (or summary), followed by a generated sentence naming the endpoint — “Reference for the GET /pets endpoint in the Petstore API.” — so a spec of terse one-line summaries still ships a distinct, snippet-length description per page. That sentence is English. On a site whose spec prose is written in another language, set seoDescriptionSuffix: false on the source to drop it and describe each page with the authored prose alone; an operation with neither a description nor a summary falls back to its title (GET /pets), so no page ships an empty description:
reference: [
openapi({
sources: [{ spec: "./openapi.de.json", seoDescriptionSuffix: false }],
}),
],
A scalar() embed takes only noindex of these — it already sits outside Blume’s search and llms.txt, so the two include* settings have nothing to act on there.
Overlays
When the spec is generated by another tool or owned by another team, keep your docs changes out of it with an OpenAPI Overlay: a separate file that lists changes to make to the spec, such as clearer descriptions, a public server URL, or internal operations removed. List overlays beside spec. Each is a local path or an http(s) URL, and they apply in order:
reference: [
openapi({
spec: "./openapi.json",
overlays: ["./overlays/public.yaml"],
}),
],
overlay: 1.1.0
info:
title: Public docs
version: 1.0.0
actions:
- target: $.paths.*[?@['x-internal'] == true]
remove: true
- target: $.info
update:
description: The public Acme API.
Each action’s target is a JSONPath expression. update merges into every node it selects: objects merge key by key, arrays gain the new items, and other values are replaced. copy merges in a node from elsewhere in the spec (Overlay 1.1), and remove: true deletes the targets. Overlay Specification 1.0 and 1.1 are both supported.
Overlays apply to the spec as written, before Blume upgrades it to OpenAPI 3.1, so an overlay targets the version you wrote the spec in. Everything built from the spec sees the result: the operation pages, the sidebar, search, llms.txt, and the MCP server. With sources, give each source its own overlays. An overlay that doesn’t apply fails the reference the way an unreadable spec does, so a broken overlay can’t publish what it was meant to hide.
Remove internal endpoints from public API docs walks through an overlay that strips internal operations from a generated spec on every build.
Authorization
Operations that declare security requirements render an Authorization section above their parameters, and the generated code samples send a placeholder credential (Authorization: Bearer YOUR_TOKEN, an API-key header, or a query key — whatever the scheme calls for). There’s nothing to configure: Blume reads security from the spec, so the reference always matches what the API actually enforces.
The OpenAPI semantics carry over as written:
- An operation’s own
securityoverrides the document’s root default;security: []marks it public and renders no Authorization section. - Multiple requirement entries are alternatives — rendered as “or” groups; every scheme inside one entry is required together. The first alternative feeds the code samples.
- An empty
{}entry means auth is optional for that operation, and the section says so. - OAuth2 scopes are listed per scheme; scheme
descriptions fromcomponents.securitySchemesrender inline.
Webhooks and callbacks
Your API’s outgoing requests get documented too. Each webhook in the spec’s top-level webhooks gets its own page, like an operation: filed under its first tag, or a Webhooks group when it has none. The page shows the payload’s schema and the responses your endpoint should send back. In place of the Try it panel and code samples, the rail shows an example payload, since your API sends this request rather than serving it. A webhook page lists only the security the webhook declares itself: the spec’s root security guards calls to your API, so it doesn’t apply to requests the API sends. A webhook that’s a $ref to a shared path item is left out, with a BLUME_OPENAPI_REF_PATH_ITEM warning.
webhooks:
newPet:
post:
summary: New pet added
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/Pet"
responses:
"200":
description: Return a 200 to acknowledge the event.
A 3.0 spec’s webhooks under the x-webhooks extension become webhooks when Blume upgrades the spec to 3.1. A spec already at 3.1 or later isn’t upgraded, so webhooks it still keeps under x-webhooks render nothing, with a BLUME_OPENAPI_X_WEBHOOKS warning: rename the field to webhooks.
An operation’s callbacks, the requests your API makes back to a URL the caller supplied, render in a Callbacks section on that operation’s page. Each shows the URL expression ({$request.body#/callbackUrl}), its method, its parameters (a signature header, say), its request body, and its expected responses. Callbacks declared under components.callbacks and referenced with $ref work the same way.
Webhook pages are real pages: they’re in search, llms.txt, and the MCP server, and their Markdown tells agents it’s a request the API sends, so an agent won’t try to call it.
Write webhook documentation with OpenAPI 3.1 adds signature headers and acknowledgments to the webhook pages, plus a receiving guide with a working Node.js receiver.
Spec warnings
Blume doesn’t validate a spec against the OpenAPI schema: run a linter such as Spectral or oasdiff in CI for that. It does warn, in blume dev, blume build, and blume check, about mistakes that would otherwise render wrong without any sign, and about parts of a spec it leaves out. Each warning names the spec, and the rest of the reference still builds:
| Code | What’s wrong |
|---|---|
BLUME_OPENAPI_PATH_PARAMETER_MISSING |
A {name} in an operation’s path has no in: path parameter of that name, so the page has no input for it, and Try it and the code samples send the placeholder as literal text. |
BLUME_OPENAPI_PATH_PARAMETER_UNUSED |
An in: path parameter has no {name} in the path, so the value Try it asks for never reaches the URL. |
BLUME_OPENAPI_UNKNOWN_SECURITY_SCHEME |
A security requirement names a scheme that components.securitySchemes doesn’t define, so the Authorization section can’t describe it and requests send no credential for it. |
BLUME_OPENAPI_UNSUPPORTED |
The spec uses an OpenAPI 3.2 feature Blume doesn’t render yet: additionalOperations, which leaves those operations out, or an in: querystring parameter, which leaves the query string out of the page, Try it, and the code samples. |
BLUME_OPENAPI_CODE_SAMPLE_REF |
An x-codeSamples entry’s source is a $ref Blume can’t read, or one that points inside a document, so that sample is left out. |
BLUME_OPENAPI_X_WEBHOOKS |
A 3.1 or later spec keeps its webhooks under x-webhooks, the extension 3.0 tools read, and has no webhooks, so none of them render. Rename the field to webhooks. |
BLUME_OPENAPI_REF_PATH_ITEM |
A path or webhook is a $ref to a shared path item, such as one under components.pathItems. Blume doesn’t resolve these, so its operations are missing from the reference. Inline the path item under paths or webhooks; its schemas, parameters, and responses can stay $refs. |
BLUME_OPENAPI_DUPLICATE_OPERATION_ID |
Two operations share an operationId, which must be unique in a spec. The warning names both. Blume adds the method to the second one’s id in <Operation id>, and to its URL when the two share a tag. Give each operation its own operationId. |
BLUME_OPENAPI_LOCAL_SERVER |
A server the spec, a path, or an operation declares is a local address (localhost, 127.0.0.1, 0.0.0.0, [::1]), often written by a framework that exported the spec on a dev machine. Try it and the code samples would send requests there, which readers can’t reach. List the public URL in servers, or set it with an overlay. |
BLUME_OPENAPI_NULLABLE |
A spec that declares OpenAPI 3.1 or later uses nullable, which 3.1 removed. Blume still shows those values as nullable, but validators and SDK generators that follow 3.1 read them as never null. The warning names where, as JSON Pointers into the spec. Add "null" to the schema’s type instead (type: [string, "null"]). |
Keep API documentation in sync with backend changes in CI runs those checks in GitHub Actions: it exports the spec from the backend, fails when the spec is stale, invalid, or breaking, and rebuilds the reference from it.
Operations in your own pages
Any MDX page can show an operation from a reference with <Operation>: its authorization, parameters, request body, and responses, beside the Try it panel and code samples, as the operation’s own page shows them. A quickstart can walk through the one call it’s about without sending readers away:
---
title: Quickstart
mode: wide
---
Create your first pet:
<Operation source="reference" id="add-pet" />
source names the reference by its route, without slashes and lowercased with hyphens (/reference is reference, /api/v2 is api-v2). id names the operation: its operationId, lowercased with its words split by hyphens (addPet is add-pet), or its method and path (post-pets) when it has none. The surest way to get both is an operation page’s raw MDX copy, at its URL plus .mdx, whose body is the tag to copy. The operation lays out in two columns, so give the page mode: wide: beside the table of contents, the left column is narrow. In the page’s Markdown copy the tag becomes the endpoint, request body, and responses, as on the operation’s own page.
Embedding Scalar instead
openapi() always renders Blume’s own pages. To embed Scalar’s self-contained API reference UI on a single route instead — its own sidebar, search, theme, and request client — list a scalar() adapter from blume/reference in place of (or beside) this one. The Scalar page covers what the embed does and doesn’t do, and how to pass Scalar’s own options through.