Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

API reference

Publish a FastAPI docs site with guides and API reference

Export your FastAPI app's OpenAPI spec without starting a server, and publish it as a separate docs site with a getting-started guide beside the API reference.

By 11 min read

FastAPI already writes an OpenAPI 3.1 document from your routes and Pydantic models: it's what powers the Swagger UI at /docs. To give the API a standalone documentation website, export that document to a file with a short script and commit it into a Blume project. Blume generates a page for every operation, next to guides you write in Markdown. The docs build from the file, so they deploy on their own host and never need the API to be running.

By the end of this guide, you have a small FastAPI service with typed models and bearer authentication, a script that exports its spec without starting a server, and a docs site with a getting-started guide, a generated API reference, and a Try it panel your API accepts through CORS.

If your only readers are your own team and the reference is all they need, you may not need any of this. FastAPI's built-in docs are already there and always match the running code. A separate site earns its place when outside developers need tutorials, search, and stable URLs that stay up when the API doesn't.

Where FastAPI's /docs fits

Out of the box, FastAPI serves the spec at /openapi.json, Swagger UI at /docs, and ReDoc at /redoc, all built from the code it's running. That makes them the right tool while you build. A docs site does a different job:

FastAPI's /docs and /redocA Blume site
Served byThe API itselfAny static host, separate from the API
ContentThe referenceGuides and the reference, in one sidebar and one search
UpdatesOn every reloadWhen you export the spec and rebuild
AvailableWhile the API is upAlways, as static files

You can keep both. To switch FastAPI's pages off in production, pass docs_url=None and redoc_url=None to FastAPI(), and openapi_url=None to stop serving the spec too. The export script below calls app.openapi() directly, so it keeps working with all three off.

Set up the two projects

The example keeps the API and its docs in one repository, as two folders:

acme/
  acme-api/    the FastAPI service (Python, uv)
  docs-site/   the Blume docs (Node.js)

Create both, then add FastAPI to the API project. The standard extra brings the fastapi CLI and Uvicorn for running it locally.

mkdir acme && cd acme
uv init --app acme-api
npx blume init docs-site --template docs --yes
cd acme-api
uv add "fastapi[standard]==0.141.1"

blume init writes docs-site/ with a docs/ content folder, a blume.config.ts, and a package.json whose dev and build scripts run Blume, then installs its dependencies.

Write typed request and response models

Replace acme-api/main.py with the Acme Messages API: send an email or SMS, then check whether it arrived. Everything the reference shows comes from this file.

import os
from typing import Annotated, Literal
from uuid import uuid4

from fastapi import Depends, FastAPI, HTTPException, Path
from fastapi.middleware.cors import CORSMiddleware
from fastapi.routing import APIRoute
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field


def route_name(route: APIRoute) -> str:
    # Use the function name as the operation ID: send_message, not
    # send_message_messages_post.
    return route.name


app = FastAPI(
    title="Acme Messages API",
    version="1.0.0",
    description="Send transactional email and SMS, and check whether each one arrived.",
    openapi_tags=[
        {
            "name": "Messages",
            "description": "Send a message and check whether it arrived.",
        },
    ],
    generate_unique_id_function=route_name,
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=os.environ.get("DOCS_ORIGINS", "https://docs.acme.example").split(","),
    allow_methods=["GET", "POST"],
    allow_headers=["Authorization", "Content-Type"],
)

bearer = HTTPBearer(
    scheme_name="bearerAuth",
    description="An API key, sent as a bearer token.",
)


def require_api_key(
    credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer)],
) -> str:
    if not credentials.credentials.startswith("acme_"):
        raise HTTPException(status_code=401, detail="Invalid API key.")
    return credentials.credentials


class SendMessageRequest(BaseModel):
    channel: Literal["email", "sms"] = Field(
        description="How to deliver the message.",
    )
    to: str = Field(
        description="An email address, or a phone number in E.164 format.",
        examples=["ada@example.com"],
    )
    template: str = Field(
        description="The ID of the template to send.",
        examples=["welcome"],
    )
    variables: dict[str, str] = Field(
        default_factory=dict,
        description="Values for the template's variables.",
        examples=[{"name": "Ada"}],
    )


class Message(BaseModel):
    id: str = Field(description="The message ID.", examples=["msg_8f2k"])
    status: Literal["queued", "sent", "delivered", "failed"] = Field(
        description="Where the message is in delivery.",
    )
    channel: Literal["email", "sms"]


messages: dict[str, Message] = {}


@app.post(
    "/messages",
    tags=["Messages"],
    summary="Send a message",
    status_code=202,
    response_description="The message is queued.",
    dependencies=[Depends(require_api_key)],
)
def send_message(body: SendMessageRequest) -> Message:
    """Queues an email or SMS for delivery and returns its ID right away."""
    message = Message(id=f"msg_{uuid4().hex[:8]}", status="queued", channel=body.channel)
    messages[message.id] = message
    return message


@app.get(
    "/messages/{id}",
    tags=["Messages"],
    summary="Get a message",
    response_description="The message.",
    responses={404: {"description": "No message has that ID."}},
    dependencies=[Depends(require_api_key)],
)
def get_message(
    id: Annotated[
        str,
        Path(
            description="The message ID, from the response to Send a message.",
            examples=["msg_8f2k"],
        ),
    ],
) -> Message:
    """Returns a message and its delivery status."""
    if id not in messages:
        raise HTTPException(status_code=404, detail="No message has that ID.")
    return messages[id]

Messages live in a dictionary to keep the example short. Here's where each part ends up on the docs site:

  • Models. SendMessageRequest and Message become schemas. Each field's description fills the schema tables, and its examples fill the examples and the Try it form.
  • Summary and docstring. summary is the page title and sidebar label. The docstring becomes the operation's description: the page's introduction and its search result text.
  • Tags. tags groups operations, and openapi_tags gives each group the description shown on the reference overview.
  • Responses. status_code and response_description describe the success response, and responses adds the 404. FastAPI also adds a 422 Validation Error response to every operation that validates input, and the reference shows it like any other.

Operation IDs become URLs

Blume builds each operation's URL from its first tag and its operation ID. FastAPI's default ID joins the function name, path, and method, so send_message would get send_message_messages_post and a page at /api/messages/send-message-messages-post. Passing route_name as generate_unique_id_function uses the function name alone, so the pages land at /api/messages/send-message and /api/messages/get-message. Function names then have to be unique across the app. FastAPI warns with Duplicate Operation ID when two collide.

Security schemes

Authentication goes through HTTPBearer from fastapi.security. Because it's a security class, FastAPI writes it to components.securitySchemes under the scheme_name you give it, and adds a security requirement to every operation that depends on it. Blume reads both: each operation page gets an Authorization section, the Try it panel gets a Bearer token field, and the code samples send Authorization: Bearer YOUR_TOKEN.

APIKeyHeader, HTTPBasic, and OAuth2PasswordBearer work the same way. For OAuth2, the panel takes a pasted access token rather than running the flow.

Start the API and try it in Swagger UI:

uv run fastapi dev main.py

Open http://127.0.0.1:8000/docs. A request without a key gets a 401 with Not authenticated, and one with a key that starts with acme_ gets a 202.

Export the spec without starting the server

Add a script beside main.py:

"""Write the API's OpenAPI document to a file, without starting the server."""

import json
import sys
from pathlib import Path

from main import app

spec = app.openapi()
spec["servers"] = [
    {"url": "https://api.acme.example/v1", "description": "Production"},
]

out = Path(sys.argv[1] if len(sys.argv) > 1 else "openapi.json")
out.write_text(json.dumps(spec, indent=2) + "\n")
print(f"Wrote {out}")

Run it from acme-api, writing into the docs project:

uv run python export_openapi.py ../docs-site/openapi.json

The script imports the app and calls app.openapi(), which builds the document in memory from your routes and models. Nothing listens on a port, no request goes out, and FastAPI's lifespan handler doesn't run. So the export works the same on a laptop or a CI runner, with no database and no production API to reach. Keep connections in the lifespan handler, not at import time, and it stays that way.

Set the server URL

FastAPI's document has no servers entry unless you configure one, and here that matters. Blume's Try it panel and code samples send requests to the spec's first server. With none, they use the bare path, like /messages, which the browser sends to the docs site itself.

The script adds the production server to the exported file, not to the app, so FastAPI's own /docs keeps targeting whichever host serves it. The Acme API sits under /v1 behind a gateway, so the paths stay /messages and the server URL carries the prefix. If your routes include the prefix themselves, say from an APIRouter with prefix="/v1", use the bare origin instead.

Build the docs site

Point Blume at the exported file and give the reference a header tab. Replace docs-site/blume.config.ts:

import { defineConfig } from "blume";
import { openapi } from "blume/reference";

export default defineConfig({
  title: "Acme Docs",
  description: "Guides and API reference for the Acme Messages API.",
  navigation: {
    tabs: [
      { label: "Docs", path: "/" },
      { label: "API reference", path: "/api" },
    ],
  },
  reference: [openapi({ route: "/api", spec: "./openapi.json" })],
});

The reference mounts at /api. It never adds a tab on its own, so the API reference tab is what makes it reachable. For the reference's other options, like which code-sample languages to show, see Generate API docs from an OpenAPI spec.

Write the getting-started guide

The reference answers "what does this endpoint take?" A getting-started guide answers "how do I make my first call?" Replace the scaffolded home page first:

---
title: Acme Messages API
description: Send transactional email and SMS from your app with one API call, then check whether each message was delivered.
---

The Acme Messages API sends transactional email and SMS for your app.

- [Getting started](/getting-started): send your first message and check that it arrived.
- [API reference](/api): every endpoint, generated from the API's own code.

Then add the guide beside it:

---
title: Getting started
description: Authenticate with an API key, send your first message with the Acme Messages API, and check whether it was delivered.
---

Every request needs an API key, sent as a bearer token in the `Authorization`
header. Keys start with `acme_`.

## 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 delivery

Pass the ID to [Get a message](/api/messages/get-message):

```bash
curl https://api.acme.example/v1/messages/msg_8f2k \
  -H "Authorization: Bearer $ACME_API_KEY"
```

`status` moves from `queued` to `sent`, then to `delivered` or `failed`.

## Errors

A missing or invalid key returns `401`. A body that fails validation returns
`422`, with a `detail` list naming each field that failed.
[Send a message](/api/messages/send-message) lists every field.

The guide links to operation pages by URL like any other page: guides and reference share one sidebar and one search. Start the dev server from docs-site:

npm run dev

Open http://localhost:4321 and you have these pages:

PageFrom
/docs/index.mdx
/getting-starteddocs/getting-started.mdx
/apiThe spec's title, description, and tags
/api/messages/send-messagePOST /messages
/api/messages/get-messageGET /messages/{id}

Allow the docs site through CORS

The Try it panel sends requests from the reader's browser straight to your API. The docs and the API are on different origins, and each request carries an Authorization header and a JSON body, so the browser first sends a preflight OPTIONS request. It only sends the real request if the API answers that it allows the docs origin.

That's the CORSMiddleware block in main.py. It allows the origins in DOCS_ORIGINS, set per environment, and only the methods and headers the panel uses. The panel sends no cookies, so allow_credentials stays off.

You can test this without production. Stop the API and start it again, allowing the docs dev server's origin:

DOCS_ORIGINS=http://localhost:4321 uv run fastapi dev main.py

Then send the preflight a browser would:

curl -i -X OPTIONS http://127.0.0.1:8000/messages \
  -H "Origin: http://localhost:4321" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: authorization,content-type"

A 200 with access-control-allow-origin: http://localhost:4321 means the browser will go ahead. A 400 with Disallowed CORS origin means the origin isn't in the list.

Now run the whole loop in the browser. With both dev servers running, open /api/messages/send-message, expand Try it, and enter http://127.0.0.1:8000 as the Custom base URL. Put a token that starts with acme_ in the Bearer token field and click Send: the panel shows the 202 and the queued message. Any other token gets the 401.

Keep the spec in sync

The reference changes only when openapi.json does. After you change a route or a model, run the export again and commit the new file with the code change. The dev server doesn't watch the spec file, so restart npm run dev to see the change.

To catch a forgotten export, run the script in CI and fail when the file changes:

cd acme-api
uv run python export_openapi.py ../docs-site/openapi.json
git diff --exit-code ../docs-site/openapi.json

It needs only the checkout: no running server and no production API. Keep API documentation in sync with backend changes in CI turns it into a full workflow.

Renaming a route function changes its operation ID, and changing its first tag changes its group, so either one moves its page. Add a redirect from the old URL in blume.config.ts so links to it keep working.

Deploy the docs separately

From docs-site, build the site and check its links:

npm run build
npx blume validate

blume build fails if it can't read the spec, and blume validate checks every internal link, including the getting-started guide's links to operation pages.

The result in dist/ is static HTML for any static host. The build reads the committed openapi.json and never runs Python, so the docs need nothing from the API's runtime or deployment. Point your host at the docs-site folder:

SettingValue
Root directorydocs-site
Build commandnpm run build
Output directorydist
Node.js version22.12 or later

On Vercel, the Root Directory setting is under Build and Deployment in the project settings. A build there can't read files outside that directory, which is one more reason to commit the spec inside docs-site rather than generate it during the build.

Blume detects your site's URL on Vercel and Netlify. On other hosts, Cloudflare Pages included, set it so the sitemap, canonical links, and Open Graph images use your docs domain:

deployment: {
  site: "https://docs.acme.example",
},

Last, set DOCS_ORIGINS on the API to the docs' production origin, written exactly as the browser sends it: scheme, host, and any port, with no trailing slash, like https://docs.acme.example. Preview deployments of the docs have their own origins, so Try it is blocked there unless you add them too. The pages themselves render fine.

Troubleshooting

Operation URLs end in -messages-post

The spec has FastAPI's default operation IDs. Pass generate_unique_id_function to FastAPI() as shown above, or set operation_id on a single route, then export again. If the old URLs are already live, add redirects for them.

Try it sends requests to the docs site

The spec's first server is missing or relative. Check servers in openapi.json. A spec saved from a running app's /openapi.json has no servers unless you set them, and behind a proxy with root_path set, FastAPI adds a relative server like /api/v1. Export with the script instead, which calls app.openapi() outside a request and sets an absolute URL.

Send fails, but curl works

That's CORS. Run the preflight curl above against the API with your docs origin. The 400 body names what failed: origin, method, or headers. Starlette compares origins as exact strings, so a trailing slash or the wrong scheme fails. When you add a PUT, PATCH, or DELETE route, or a header parameter, add it to allow_methods or allow_headers too.

An operation has no Authorization section

The route doesn't declare a security scheme. Reading the key with Header() documents it as an ordinary header parameter, not a scheme. Depend on a class from fastapi.security, like HTTPBearer, on every protected route, then export again.

The build fails with BLUME_OPENAPI_UNAVAILABLE

Blume couldn't read or parse the spec. Usually openapi.json was never exported or committed, or spec isn't relative to docs-site. In blume dev it's only a warning and the reference is skipped, so a working dev server can hide it until the build.

Next step

Keep the spec in sync

Fail CI when a route or model changes without a fresh export, so the reference never drifts from the API.

Read the CI guide

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init