API reference
Generate GraphQL docs from your schema
Turn a GraphQL schema into a docs site with a page per operation and type, linked types and deprecations, and a Try it panel that queries a sandbox.
By Hayden Bleasel13 min read

To publish GraphQL documentation outside an IDE, generate it from your schema file. Blume's graphql() reference reads a schema as SDL or as an introspection result and writes a static page for every query, mutation, subscription, and named type. Each page lists arguments, defaults, and deprecations, links every type to its own page, and gets its own URL, search entry, and Markdown copy for agents.
By the end of this guide, you have docs for a fictional Acme Messages API: a small GraphQL server with a sandbox mode, a reference built from its schema, a Try it panel that queries the sandbox, and a quickstart page for authentication and subscriptions.
If only your own developers need to explore the API, and they can reach it, the GraphiQL your server already ships may be enough. For a REST API, generate docs from an OpenAPI spec instead.
Reference pages or GraphiQL
GraphiQL and schema docs do different jobs, and many APIs ship both:
| GraphiQL | Blume reference | |
|---|---|---|
| Where it runs | Served by your API, as one app in the browser | Static pages on your docs site |
| Where the schema comes from | By default, an introspection query to the live endpoint | A schema file, read at build time |
| With introspection turned off | Needs the schema passed to it another way | Unaffected |
| Pages | No URL per type | A URL per operation and type, in search, the sitemap, and llms.txt |
| Writing queries | An editor with autocomplete and validation | A JSON body editor, prefilled with a generated example |
Use the reference for people who don't have access yet, and keep GraphiQL on a sandbox for people who want to explore.
Write the schema
Blume reads the schema from a file. Descriptions become page text, rendered as Markdown. The schema's own description, written above an explicit schema block, becomes the introduction on the reference's overview page.
"""
Send transactional email and SMS, and manage the templates they use.
"""
schema {
query: Query
mutation: Mutation
subscription: Subscription
}
type Query {
"Returns a message and its delivery status, or null when no message has that ID."
message("The message ID, from the response to sendMessage." id: ID!): Message
"Returns the workspace's templates, oldest first."
templates("How many templates to return." first: Int = 20): [Template!]!
}
type Mutation {
"Queues an email or SMS for delivery and returns the message right away."
sendMessage(input: SendMessageInput!): Message!
"Queues an email for delivery."
sendEmail(to: String!, template: ID!): Message!
@deprecated(reason: "Use sendMessage with channel: EMAIL.")
}
type Subscription {
"Emits the message each time its delivery status changes."
messageStatusChanged("The message to follow." id: ID!): Message!
}
"An object with a stable, unique ID."
interface Node {
id: ID!
}
"An email or SMS, and where it is in delivery."
type Message implements Node {
id: ID!
channel: Channel!
"An email address, or a phone number in E.164 format."
to: String!
status: MessageStatus!
"The template the message was rendered from."
template: Template!
subject: String
@deprecated(reason: "Subjects live on the template now. Read template.subject.")
createdAt: DateTime!
}
"A reusable message body with variables."
type Template implements Node {
id: ID!
name: String!
channel: Channel!
"The email subject line. Null for SMS templates."
subject: String
}
"What to send, and to whom."
input SendMessageInput {
channel: Channel!
"An email address, or a phone number in E.164 format."
to: String!
"The ID of the template to send."
template: ID!
}
"How a message is delivered."
enum Channel {
EMAIL
SMS
}
"Where a message is in delivery."
enum MessageStatus {
QUEUED
SENT
DELIVERED
FAILED
BOUNCED @deprecated(reason: "Bounced messages are reported as FAILED.")
}
"A date and time in UTC, like 2026-09-27T14:03:00Z."
scalar DateTime
@specifiedBy(url: "https://scalars.graphql.org/chillicream/date-time.html")It has an interface, an object that links to another, two deprecations, and a custom scalar, so you can see how each one renders.
If your server builds its schema in code, print it to a file as part of your build, with ./src/schema.js standing for the module that exports your GraphQLSchema:
import { writeFileSync } from "node:fs";
import { printSchema } from "graphql";
import { schema } from "./src/schema.js";
writeFileSync("schema.graphql", printSchema(schema));An introspection result works too, as the raw { "__schema": … } JSON or a full { "data": { "__schema": … } } response. Directives your server defines out of band, like Apollo Federation's @key, are ignored rather than rejected, so a Federation subgraph schema loads as it is. Blume takes one file per schema, so combine a schema that's split across several files first.
Run a sandbox endpoint
The Try it panel needs an endpoint to call. Here's a small server for the schema, built with GraphQL Yoga. It keeps everything in memory and sends nothing: each message moves to SENT, then DELIVERED, on a timer. It requires an API key unless you start it in sandbox mode, so you can see both cases from the docs.
{
"name": "acme-api",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"graphql": "17.0.2",
"graphql-yoga": "5.24.1"
}
}import { readFileSync } from "node:fs";
import { createServer } from "node:http";
import { GraphQLError } from "graphql";
import { createPubSub, createSchema, createYoga } from "graphql-yoga";
const typeDefs = readFileSync(
new URL("./schema.graphql", import.meta.url),
"utf8"
);
const sandbox = process.env.ACME_SANDBOX === "true";
const apiKey = process.env.ACME_API_KEY;
const pubSub = createPubSub();
const templates = [
{ channel: "EMAIL", id: "welcome", name: "Welcome email", subject: "Welcome to Acme" },
{ channel: "SMS", id: "login-code", name: "Login code", subject: null },
];
const messages = new Map();
const templateOf = (message) =>
templates.find((template) => template.id === message.templateId);
// Nothing is really sent: each message moves to SENT, then DELIVERED.
const send = ({ channel, template, to }) => {
if (!templates.some(({ id }) => id === template)) {
throw new GraphQLError(`No template has the ID "${template}".`, {
extensions: { code: "BAD_USER_INPUT" },
});
}
const message = {
channel,
createdAt: new Date().toISOString(),
id: `msg_${messages.size + 1}`,
status: "QUEUED",
templateId: template,
to,
};
messages.set(message.id, message);
for (const [delay, status] of [
[2000, "SENT"],
[4000, "DELIVERED"],
]) {
setTimeout(() => {
message.status = status;
pubSub.publish("status", message.id, message);
}, delay);
}
return message;
};
const resolvers = {
Message: {
subject: (message) => templateOf(message).subject,
template: templateOf,
},
Mutation: {
sendEmail: (_, { template, to }) => send({ channel: "EMAIL", template, to }),
sendMessage: (_, { input }) => send(input),
},
Node: {
__resolveType: (node) => ("templateId" in node ? "Message" : "Template"),
},
Query: {
message: (_, { id }) => messages.get(id) ?? null,
templates: (_, { first }) => templates.slice(0, first),
},
Subscription: {
messageStatusChanged: {
resolve: (message) => message,
subscribe: (_, { id }) => pubSub.subscribe("status", id),
},
},
};
const yoga = createYoga({
context: ({ request }) => {
const authorization = request.headers.get("authorization");
if (!sandbox && authorization !== `Bearer ${apiKey}`) {
throw new GraphQLError("Send your API key as a bearer token.", {
extensions: { code: "UNAUTHENTICATED", http: { status: 401 } },
});
}
return {};
},
cors: {
origin: ["http://localhost:4321", "https://docs.acme.example"],
},
graphiql: sandbox,
schema: createSchema({ resolvers, typeDefs }),
});
createServer(yoga).listen(4000, () => {
console.log("Acme Messages API on http://localhost:4000/graphql");
});Install it and start it in sandbox mode:
cd acme-api
npm install
ACME_SANDBOX=true npm startFrom another terminal, check that it answers without a key:
curl http://localhost:4000/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ templates { id name } }"}'It returns both templates. cors.origin lists the origins a browser may call the API from: the local docs server and the production docs site. GraphiQL is served at the same URL, in sandbox mode only.
Create the docs project
In the folder beside acme-api, scaffold a Blume project:
npx blume init acme-docs --template docs
cd acme-docs
cp ../acme-api/schema.graphql .Keep the default docs content folder when it asks. If both projects live in one repository, skip the copy and point spec at ../acme-api/schema.graphql, so the docs always build from the server's schema. Then replace blume.config.ts:
import { defineConfig } from "blume";
import { graphql } from "blume/reference";
export default defineConfig({
title: "Acme Docs",
navigation: {
tabs: [
{ label: "Docs", path: "/" },
{ label: "GraphQL API", path: "/graphql" },
],
},
reference: [
graphql({
endpoint: "https://sandbox.acme.example/graphql",
sources: [{ label: "Acme Messages API", spec: "./schema.graphql" }],
}),
],
});The graphql() adapter mounts the reference at /graphql. It never adds a header tab, so the tab pointing at that route is what makes it reachable, and it scopes the sidebar too. The source's label titles the overview page, otherwise called "GraphQL". A schema names no server, so endpoint is where the Try it panel and code samples send requests. Replace it with your sandbox's real URL: .example domains don't resolve.
Start the dev server:
npm run devWhat gets generated
The GraphQL API tab opens an overview at /graphql with the schema's description, the endpoint, and a section per group. The sidebar groups pages into Queries, Mutations, Subscriptions, Objects, Input Objects, Enums, Interfaces, and Scalars, plus Unions when a schema has them:
| In the schema | Page |
|---|---|
message query | /graphql/queries/message |
templates query | /graphql/queries/templates |
sendMessage mutation | /graphql/mutations/send-message |
sendEmail mutation (deprecated) | /graphql/mutations/send-email |
messageStatusChanged subscription | /graphql/subscriptions/message-status-changed |
Message type | /graphql/objects/message-object |
Template type | /graphql/objects/template |
SendMessageInput | /graphql/input-objects/send-message-input |
Channel, MessageStatus | /graphql/enums/channel, /graphql/enums/message-status |
Node | /graphql/interfaces/node |
DateTime | /graphql/scalars/date-time |
Names are split at their capitals, so sendMessage becomes send-message. Page keys are unique across the schema and root fields claim theirs first, so the message query took message and the Message type got its kind appended. Built-in scalars like String and the root types themselves get no page.
An operation page lists its arguments, marking required ones and showing defaults, and links its return type. Beside them are request samples in curl, JavaScript, and Python, each sending a generated example operation and its variables, and an example response. Set codeSamples to choose other languages, or false for none.
Type relationships and deprecations
Every type name on a page links to that type's page, so a reader can walk from sendMessage to SendMessageInput to Channel without searching. Type pages also show how types connect:
- Implements and Implemented by. The
Messagepage says it implementsNode, and theNodepage listsMessageandTemplate. A union's page lists its member types the same way. - Used by. The operations that return or accept the type, and the types that reference it.
Templateis used by thetemplatesquery and theMessagetype, andChannelbyMessage,Template, andSendMessageInput. - Specified by. A custom scalar with
@specifiedBylinks to its specification, asDateTimedoes.
@deprecated works on fields, arguments, input fields, and enum values. Blume labels each one deprecated and prints its reason. A deprecated operation like sendEmail also gets a pill in the sidebar and a marker in its Markdown copy, so agents see it too. Reasons render as plain text and descriptions as Markdown, so name the replacement in the reason and put links in the description.
Generated examples select every field, deprecated ones included, so the example for message asks for subject. Keep deprecated fields working until you remove them.
Try a query from the docs
Open http://localhost:4321/graphql/queries/templates and expand Try it. Its Base URL is the endpoint from your config. To call your local server instead, type http://localhost:4000/graphql into Custom base URL, which overrides it.
The request body is the JSON the API receives, a query and its variables, prefilled from the generated example. The variables are placeholders: first starts at 0, not the argument's default. Change it to 5, select Send, and the panel shows both templates. The code samples follow your edits, so a copied curl command matches what Send did. On sendMessage, replace "id" and "string" with "welcome" and an email address, or the server rejects the template ID.
The panel sends no credentials
Stop the server and start it the way production would run it, with a key:
ACME_API_KEY=test_key npm startSend again, and the response is a 401 with an UNAUTHENTICATED error. On GraphQL pages the panel has no Authorization field: it sends Content-Type: application/json and the body, nothing else. The browser doesn't attach your API's cookies to the cross-origin request, and the code samples carry no token either.
So point endpoint at a controlled endpoint: a sandbox that answers without a key, holds only sample data, and sends nothing. Put the production URL and auth header on a page you write. If you can't run a sandbox, set playground: false and keep the examples.
CORS
The panel calls the API from the reader's browser. A JSON POST triggers a CORS preflight, so the API has to answer it for the docs origin. Check the sample server's answer without a browser:
curl -i -X OPTIONS http://localhost:4000/graphql \
-H "Origin: http://localhost:4321" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type"A listed origin gets access-control-allow-origin: http://localhost:4321 back. Any other origin gets null, and the browser blocks the send. That includes preview deployments on their own URLs, and a dev server that moved off port 4321.
If you can't change the API's CORS settings, route sends through Blume's built-in proxy. It runs as a server route, so it needs a host adapter from blume/deploy, or the build fails with BLUME_SERVER_FEATURE_REQUIRED.
import { defineConfig } from "blume";
import { vercel } from "blume/deploy";
import { graphql } from "blume/reference";
export default defineConfig({
// ...title and navigation as before
deployment: vercel(),
reference: [
graphql({
endpoint: "https://sandbox.acme.example/graphql",
playground: { proxy: true },
sources: [{ label: "Acme Messages API", spec: "./schema.graphql" }],
}),
],
});The proxy only forwards to the origins of your documented APIs, here the origin of endpoint, and only the headers the panel set. A Custom base URL isn't one of them, so with the proxy on, local sends get a 403. See Fix CORS errors in an API documentation playground for more.
Subscriptions need a page you write
The messageStatusChanged page shows the generated subscription, its variables, and an example event, but no Try it panel or HTTP samples: a subscription doesn't run over a single POST. Send the sample server one as a JSON POST and it answers 406 Not Acceptable. GraphQL Yoga streams subscriptions as server-sent events over a GET that accepts text/event-stream. Other servers use a WebSocket protocol, like graphql-ws, which needs a different client.
The schema can't say which transport your API uses, so write it down. A page in a folder named after the reference route joins the GraphQL API tab's sidebar:
---
title: Quickstart
description: Authenticate, send your first message, and follow its delivery status with the Acme Messages GraphQL API.
---
Send every request to `https://api.acme.example/graphql`, with your API key
as a bearer token.
## Send a message
```bash
curl https://api.acme.example/graphql \
-H "Authorization: Bearer $ACME_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"mutation { sendMessage(input: { channel: EMAIL, to: \"ada@example.com\", template: \"welcome\" }) { id status } }"}'
```
The response holds the message ID, like `msg_1`.
## Follow its delivery status
[messageStatusChanged](/graphql/subscriptions/message-status-changed) streams
over server-sent events, not WebSockets. Send it as a GET request that accepts
`text/event-stream`:
```bash
curl -N -G https://api.acme.example/graphql \
-H "Accept: text/event-stream" \
-H "Authorization: Bearer $ACME_API_KEY" \
--data-urlencode 'query=subscription { messageStatusChanged(id: "msg_1") { id status } }'
```
Each status change after you subscribe arrives as an event:
```text
event: next
data: {"data":{"messageStatusChanged":{"id":"msg_1","status":"SENT"}}}
```
The sandbox at `https://sandbox.acme.example/graphql` takes the same requests
without a key, and sends nothing.The page lives at /graphql/quickstart. Try its commands against your local server first, with http://localhost:4000/graphql in place of the API URL and export ACME_API_KEY=test_key in both terminals. On a freshly started server the first message is msg_1, so start the subscription in one terminal, then send the message from another. The stream prints a SENT event, then a DELIVERED one.
Build and check
Build the site, serve the build locally, and check its links:
npm run build
npx blume preview
npx blume validateIn the preview, check that /graphql/objects/message-object loads on its own, that site search finds sendMessage, and that /graphql/mutations/send-email.md returns the page as Markdown, with mutation sendEmail and its deprecation marker. blume validate checks every internal link, including the quickstart's link to the subscription page.
Deploy and keep it current
Without the proxy, the site is static files in dist/ for any static host. With it, deploy to the host your adapter names. The deployment docs cover both, including setting the site URL where Blume can't detect it.
The reference rebuilds from the schema on every build, so a schema change ships with a commit and a deploy. The dev server doesn't watch the schema file, so restart it after editing.
Renaming a field or type moves its page. Deprecate first, and when you remove a member, redirect its page to the replacement:
redirects: [
{ from: "/graphql/mutations/send-email", to: "/graphql/mutations/send-message" },
],Troubleshooting
The build fails with BLUME_GRAPHQL_UNAVAILABLE
Blume couldn't read or parse the schema, and the message says why: a wrong path, a failed download, or invalid SDL. Unknown type "Template" usually means the schema is split across files and spec points at one of them. In blume dev this is only a warning and the reference is skipped, so a working dev server can hide it.
The reference has types but no queries
The file declares no Query, Mutation, or Subscription type. Point spec at the full schema, not a file of shared types.
Send says the browser blocked the request
That's CORS: allow the docs origin in your API, or turn on the proxy. The message tells you to set playground: { proxy: true } on the openapi() reference, whatever kind of page you're on. For a GraphQL reference, set it on graphql() instead, with an absolute endpoint, as shown in Try a query from the docs.
Send returns UNAUTHENTICATED
The panel can't send a key. Point endpoint at a sandbox that answers without one, or set playground: false.
Send returns a 403 with the proxy on
The target isn't an origin the proxy allows: either a Custom base URL is set, or the reference has no absolute endpoint, which the build also warns about.
Try it returns null or an empty list
The prefilled variables are placeholders like "id" and 0. Replace them with real values before you send.
Next step
Start your GraphQL docs
Scaffold a project, copy your schema beside it, and add the graphql() reference to your config.
npx blume initA step here not working for you? Report a broken step.