API reference
Create interactive API documentation without an OpenAPI spec
Three hand-written MDX endpoint pages with a Try it panel, request samples, and response examples, tested against a local sample API. No spec needed.
By Hayden Bleasel13 min read

Yes. In Blume, an MDX page with an api line in its frontmatter documents one endpoint with no spec at all. You describe the request with <ParamField> components, and Blume builds what a generated OpenAPI page gets from them: the method and path at the top, a Try it panel that sends real requests, and request samples in cURL, JavaScript, and Python.
By the end of this guide you have three hand-written endpoint pages for a small messages API, a local copy of that API to test them against, and a site ready to publish. The cost is upkeep: every field, type, and example on these pages is something you keep in step with the API yourself.
If you have an OpenAPI spec, generate your API docs from it instead, so the reference changes when the API does. Hand-written pages suit a few endpoints, an API with no spec, or endpoints that read better written by hand. For many endpoints that change often, a spec generated from your code, for example from Hono and Zod schemas, costs less to keep current.
What you maintain by hand
Without a spec, the page is the endpoint's only description:
| You write | In | Blume uses it for |
|---|---|---|
| The method and path | api in the page's frontmatter | The header at the top of the page, and the request URL |
| The base URL | api.server in blume.config.ts | The start of every request URL |
| How requests authenticate | api.auth, or authMethod on a page | The credential input in Try it, and the header in every sample |
| Each request field's name, location, type, and whether it's required | <ParamField> attributes | The field rows, the Try it inputs, the samples, and the body check before Send |
| Example values | placeholder and default on each field | The values the samples and Try it start with |
| Descriptions | The text inside each field | The field rows on the page |
| Response fields and examples | <ResponseField>, <ResponseExample> | Shown as written |
Nothing on the page is checked against the running API: rename a field in the API, and the page keeps the old name until you edit it. Some of what a spec can say never reaches Try it. Descriptions, and any format or limit you write in them, stay on the field rows, and an enum<string> field is a text input, not a menu. Bodies are always JSON, each page has one auth method, and response fields are documentation only.
Mintlify uses the same frontmatter keys and components, so endpoint pages moved from Mintlify carry over as they are.
Run the sample API
The pages in this guide document a small Acme Messages API: send an email or SMS from a template, look a message up, and list the templates. To test the pages against something real, save this server as sample-api.mjs in an empty folder. It uses only Node's standard library, so there's nothing to install.
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
const PORT = 3030;
const API_KEY = "sk_test_acme";
const DOCS_ORIGIN = "http://localhost:4321";
const templates = [
{ id: "welcome", name: "Welcome email", channel: "email" },
{ id: "password-reset", name: "Password reset", channel: "email" },
{ id: "login-code", name: "Login code", channel: "sms" },
];
const messages = new Map();
const sentKeys = new Map();
const reply = (res, status, body) => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(body === undefined ? "" : JSON.stringify(body, null, 2));
};
const fail = (res, status, code, message) =>
reply(res, status, { error: { code, message } });
const readJson = async (req) => {
let text = "";
for await (const chunk of req) {
text += chunk;
}
try {
return JSON.parse(text);
} catch {
return null;
}
};
const sendMessage = async (req, res) => {
const body = await readJson(req);
if (!body || typeof body !== "object") {
return fail(res, 400, "invalid_json", "The body must be a JSON object.");
}
const { channel, to, template, variables = {} } = body;
if (channel !== "email" && channel !== "sms") {
return fail(res, 400, "invalid_channel", "channel must be email or sms.");
}
if (typeof to !== "string" || to === "") {
return fail(res, 400, "missing_to", "to is required.");
}
if (!templates.some((entry) => entry.id === template)) {
return fail(res, 400, "unknown_template", "No template has the ID " + template + ".");
}
const key = req.headers["idempotency-key"];
if (key && sentKeys.has(key)) {
return reply(res, 202, messages.get(sentKeys.get(key)));
}
const message = {
id: "msg_" + randomUUID().slice(0, 8),
status: "queued",
channel,
to,
template,
variables,
created_at: new Date().toISOString(),
};
messages.set(message.id, message);
if (key) {
sentKeys.set(key, message.id);
}
return reply(res, 202, message);
};
const server = createServer(async (req, res) => {
// Let the docs site's Try it panel call this API from the browser.
res.setHeader("Access-Control-Allow-Origin", DOCS_ORIGIN);
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader(
"Access-Control-Allow-Headers",
"Authorization, Content-Type, Idempotency-Key"
);
if (req.method === "OPTIONS") {
return reply(res, 204);
}
if (req.headers.authorization !== "Bearer " + API_KEY) {
return fail(res, 401, "unauthorized", "Send a valid API key as a bearer token.");
}
const url = new URL(req.url, "http://localhost");
if (req.method === "POST" && url.pathname === "/v1/messages") {
return sendMessage(req, res);
}
if (req.method === "GET" && url.pathname.startsWith("/v1/messages/")) {
const id = decodeURIComponent(url.pathname.slice("/v1/messages/".length));
const message = messages.get(id);
return message
? reply(res, 200, message)
: fail(res, 404, "not_found", "No message has that ID.");
}
if (req.method === "GET" && url.pathname === "/v1/templates") {
const channel = url.searchParams.get("channel");
const limit = Number(url.searchParams.get("limit") ?? 20);
const data = templates
.filter((entry) => !channel || entry.channel === channel)
.slice(0, limit);
return reply(res, 200, { data });
}
return fail(res, 404, "not_found", "No such endpoint.");
});
server.listen(PORT, () => {
console.log("Acme sample API on http://localhost:" + PORT + "/v1");
});Start it:
node sample-api.mjsThen check from a second terminal that it answers:
curl http://localhost:3030/v1/templates \
-H "Authorization: Bearer sk_test_acme"You get the three templates back. Two details matter for the docs: it only accepts the bearer token sk_test_acme, and its CORS headers let a page on http://localhost:4321, where blume dev serves your docs, call it from the browser.
Set up the docs project
Scaffold a Blume project in a separate folder:
npx blume init acme-docs --template docs --yesYou get a docs/ content folder, a blume.config.ts, and dev and build scripts, with Blume installed. Replace the config with this:
import { defineConfig } from "blume";
export default defineConfig({
title: "Acme Docs",
api: {
server: "https://api.acme.example/v1",
auth: { method: "bearer" },
},
});api holds what every endpoint page shares. server is the base URL each page's path joins, and auth says how requests authenticate. Point server at your production API. The api.acme.example host doesn't exist, so you'll aim Try it at the local server when you test.
Keep the reference in its own folder. A meta.ts names the sidebar group and orders its pages:
import { defineMeta } from "blume";
export default defineMeta({
title: "API reference",
pages: ["send-message", "get-message", "list-templates"],
});Then add an overview page for what applies to every endpoint. As the folder's index, it comes first in the group:
---
title: API overview
description: The Acme Messages API's base URL, how to authenticate, and the shape of its errors.
---
The Acme Messages API sends transactional email and SMS from templates. Every
endpoint lives under one base URL:
```text
https://api.acme.example/v1
```
## Authentication
Send your API key as a bearer token in the `Authorization` header of every
request:
```bash
curl https://api.acme.example/v1/templates \
-H "Authorization: Bearer $ACME_API_KEY"
```
A missing or wrong key gets a `401` response.
## Errors
Every error has the same shape: a machine-readable `code` and a sentence for
humans.
```json
{ "error": { "code": "unauthorized", "message": "Send a valid API key as a bearer token." } }
```Write the first endpoint page
An endpoint page is an MDX file whose frontmatter has an api line: an HTTP method, a space, and a path. Save this as docs/api/send-message.mdx:
---
title: Send a message
description: Queue an email or SMS from a template and get its message ID back right away.
api: POST /messages
---
Queues an email or SMS for delivery and returns the message with a status of
`queued`. Check whether it arrived with [Get a message](/api/get-message).
## Request
<ParamField header="Idempotency-Key" type="string">
A unique key for this send. Retrying with the same key returns the first
message instead of sending a second one.
</ParamField>
<ParamField body="channel" type="enum<string>" required placeholder="email">
How to deliver the message: `email` or `sms`.
</ParamField>
<ParamField body="to" type="string" required placeholder="ada@example.com">
An email address, or a phone number in E.164 format for `sms`.
</ParamField>
<ParamField body="template" type="string" required placeholder="welcome">
The ID of a template. [List templates](/api/list-templates) returns them.
</ParamField>
<ParamField body="variables" type="object">
Values for the template's variables.
<Expandable title="properties">
<ParamField body="name" type="string" placeholder="Ada">
The recipient's first name, used by the `welcome` template.
</ParamField>
</Expandable>
</ParamField>
## Response
<include>./_message.mdx</include>
<ResponseExample>
```json 202
{
"id": "msg_f1e0f988",
"status": "queued",
"channel": "email",
"to": "ada@example.com",
"template": "welcome",
"variables": { "name": "Ada" },
"created_at": "2026-09-27T23:44:11.578Z"
}
```
```json 400
{
"error": {
"code": "unknown_template",
"message": "No template has the ID welcom."
}
}
```
</ResponseExample>Here's what each part does:
- The
apiline putsPOSTand/messagesat the top of the page and gives it a Try it panel and samples. The path joinsapi.server, so the request goes tohttps://api.acme.example/v1/messages. - Each
<ParamField>says where it goes with the attribute that holds its name:path,query,header, orbody. Itstypeandrequiredshow on the field row and shape the request. placeholderis the example value the samples and Try it start with. It doesn't appear on the page itself.- A field inside another field's
<Expandable>becomes a property of that object, sovariablesis sent as{ "name": "Ada" }. <ResponseExample>shows one tab per titled code fence. On a wide screen it pins to a column beside the page, under the Try it panel and samples.- The samples are generated in cURL, JavaScript, and Python. To show your SDK instead, add a
<RequestExample>, which replaces them.
Share the response fields
Send a message and Get a message return the same message object, so write its fields once. A file whose name starts with an underscore isn't a page, and an <include> splices it in where you put it. Save this as docs/api/_message.mdx:
<ResponseField name="id" type="string" required>
The message ID. Pass it to [Get a message](/api/get-message).
</ResponseField>
<ResponseField name="status" type="enum<string>" required>
One of `queued`, `sent`, `delivered`, or `failed`.
</ResponseField>
<ResponseField name="channel" type="enum<string>" required>
`email` or `sms`.
</ResponseField>
<ResponseField name="to" type="string" required>
The recipient, as sent.
</ResponseField>
<ResponseField name="template" type="string" required>
The template the message was built from.
</ResponseField>
<ResponseField name="variables" type="object">
The variables sent with the message.
</ResponseField>
<ResponseField name="created_at" type="string" required>
When the message was queued, as an ISO 8601 timestamp.
</ResponseField>When the message object gains a field, you add it here and both pages pick it up.
Add the other two endpoints
Get a message takes the message ID in its path. Save this as docs/api/get-message.mdx:
---
title: Get a message
description: Look up a message by its ID to see whether it was queued, sent, delivered, or failed.
api: GET /messages/{id}
---
Returns a message and its delivery status.
## Request
<ParamField path="id" type="string" required placeholder="msg_f1e0f988">
The message ID, from the response to [Send a message](/api/send-message).
</ParamField>
## Response
<include>./_message.mdx</include>
<ResponseExample>
```json 200
{
"id": "msg_f1e0f988",
"status": "queued",
"channel": "email",
"to": "ada@example.com",
"template": "welcome",
"variables": { "name": "Ada" },
"created_at": "2026-09-27T23:44:11.578Z"
}
```
```json 404
{
"error": {
"code": "not_found",
"message": "No message has that ID."
}
}
```
</ResponseExample>The {id} in the api line is filled from the path field with the same name. List templates takes two optional query fields. Save it as docs/api/list-templates.mdx:
---
title: List templates
description: List the message templates in your workspace, optionally only the ones for email or for SMS.
api: GET /templates
---
Returns the templates you can pass to [Send a message](/api/send-message).
## Request
<ParamField query="channel" type="enum<string>">
Only return templates for this channel: `email` or `sms`.
</ParamField>
<ParamField query="limit" type="integer" default={20}>
How many templates to return, up to 100.
</ParamField>
## Response
<ResponseField name="data" type="object[]" required>
The templates.
<Expandable title="properties">
<ResponseField name="id" type="string" required>
The template ID, passed as `template` when you send a message.
</ResponseField>
<ResponseField name="name" type="string" required>
A readable name for the template.
</ResponseField>
<ResponseField name="channel" type="enum<string>" required>
The channel the template is written for: `email` or `sms`.
</ResponseField>
</Expandable>
</ResponseField>
<ResponseExample>
```json 200
{
"data": [
{ "id": "welcome", "name": "Welcome email", "channel": "email" },
{ "id": "password-reset", "name": "Password reset", "channel": "email" }
]
}
```
</ResponseExample>Optional query and header fields start blank in Try it and stay out of the request until a reader fills them in, so the default of 20 is documentation: it shows on the field row, and the API applies it.
How the request is built
Try it and the samples share one request builder, so apart from the credential placeholder, a copied sample is what Send would do. Each kind of field is sent like this:
| Field | Sent as |
|---|---|
path="id" | The value in place of {id}, URL-encoded |
query="limit" | ?limit=20. A string[] field repeats its name, like ?tag=a&tag=b. |
header="Idempotency-Key" | A request header with that name |
body="channel" | A property of one JSON object, sent with Content-Type: application/json |
In the body, integer, number, and boolean fields are JSON numbers and booleans, a string[] field is an array, and any other type, like enum<string>, is sent as a string. When every body field is a plain value, Try it shows one input per field. A nested object or an array, like variables here, switches it to a JSON editor for the whole body. In the JSON editor, the panel checks the body against your types and required flags and won't send until it passes, so deleting to shows body.to is required.
Authentication
api.auth applies to every endpoint page. Try it shows one credential input to match, and the samples show a placeholder in its place:
method | Sent as | Sample placeholder |
|---|---|---|
bearer | Authorization: Bearer and the token | YOUR_TOKEN |
basic | Authorization: Basic and the base64 of username:password | YOUR_CREDENTIALS |
key | The key in an x-api-key header, or the header you name in api.auth.name | YOUR_API_KEY |
none | No credential | None |
A page overrides the site's method with authMethod in its frontmatter, like authMethod: none on a public status endpoint. A page can't name its own API key header, though: key always uses api.auth.name. Try it leaves an empty credential out of the request instead of sending the placeholder.
To show the samples without Try it, set playground: simple in a page's frontmatter. It suits an endpoint readers shouldn't call from the browser, like one that deletes data. playground: none drops both and keeps the method, path, and examples.
Send a test request
With the sample API still running, start the docs from the project folder:
npm run devOpen http://localhost:4321/api/send-message, expand Try it, and:
- In Custom base URL, enter
http://localhost:3030/v1. It overridesapi.serverfor this panel, and the samples switch to it as you type. - In Bearer token, enter
sk_test_acme. - Leave the body as the placeholders filled it, and select Send.
The panel shows a 202 with the new message's ID. Open Get a message, enter the same base URL and token, paste the ID into the id field, and send for a 200. Then try the failures your pages document: clear the token for a 401, or change template to one that doesn't exist for a 400. Any response that doesn't match its page is a page to fix.
To run the same request from a terminal, tick Include my values in samples. The cURL sample then carries your token as well as the local base URL.
Publish
The live Try it panel sends requests from the reader's browser, so your production API has to allow your docs origin with CORS headers, the way the sample API allows http://localhost:4321. If it can't, route requests through Blume's built-in proxy. The proxy is a server route, so it needs a host adapter:
import { defineConfig } from "blume";
import { vercel } from "blume/deploy";
export default defineConfig({
title: "Acme Docs",
deployment: vercel(),
api: {
server: "https://api.acme.example/v1",
auth: { method: "bearer" },
playground: { proxy: true },
},
});The proxy only forwards to the origin of api.server and of any full URL in an api line, so readers can't aim it at other hosts. That also means it refuses a Custom base URL like your local server, so test locally with the proxy off. Fix CORS errors in an API documentation playground goes deeper on both, and static or server-rendered docs explains what a server build changes.
Build the site and check its links:
npm run build
npx blume validateblume validate checks the links between your endpoint pages, and reports a broken link inside _message.mdx against that file rather than the pages that include it. Each endpoint page also has a Markdown copy at its URL plus .md, like /api/send-message.md. It keeps the frontmatter, so agents see the api line, and it lists every field with its type.
Troubleshooting
The build fails with BLUME_FRONTMATTER_INVALID on the api line
api takes an HTTP method, a space, and a path or full URL, like POST /messages or GET https://api.acme.example/v1/templates. The method must be one of GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, or TRACE, in any case. A method alone, or anything else before the path, fails the page's frontmatter check.
The request URL still has a brace in it
A {name} in the path is only filled by a path field with exactly that name. If the api line says {id} and the field says path="messageId", the URL keeps {id} as written. Rename one to match the other.
Try it sends the request to the docs site
With no api.server, a path in the api line stays relative, so Send goes to your docs origin instead of the API. Set api.server, or write a full URL in the api line.
A field is sent as "string", 0, or true
A body field with no placeholder or default still goes into the sample body, with a stand-in value for its type. Give every body field one of the two. A placeholder is always text, so on a number field the samples send "5"; use default={5} there instead.
Send says the browser blocked the request
That's CORS: the API answered, but not with headers that allow your docs origin. The message tells you to set playground.proxy on the openapi() reference whatever the page, but for hand-written pages the setting is api.playground.proxy in blume.config.ts, as shown in Publish, and it needs a server build. Locally, check that the sample API's DOCS_ORIGIN matches the port blume dev printed.
The build fails with BLUME_SERVER_FEATURE_REQUIRED
api.playground.proxy: true needs server output, but the site is a static build. Set deployment to a host adapter from blume/deploy, or allow the docs origin in your API and turn the proxy off. If Blume also warns that the proxy has no origin to allow, api.server isn't an absolute URL, so the proxy would refuse every request.
Next step
Make Try it work in production
Readers call your API from their browser, so allow your docs origin with CORS or route requests through the proxy before you publish.
Read the CORS guideA step here not working for you? Report a broken step.