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

API reference

Build Django REST Framework docs with drf-spectacular

A DRF API whose drf-spectacular schema generates without warnings, published as an API reference with a first-request tutorial beside it and rebuilt from code in CI.

By 12 min read

To publish Django REST Framework documentation, describe your API with drf-spectacular, write the OpenAPI schema to a file with its spectacular management command, and point Blume at that file. Blume builds a page for every endpoint, and the onboarding guides you write sit beside them in the same sidebar, search, and build.

By the end, your Django repository has a small DRF API with bearer-token auth and pagination, a schema that generates without warnings, and a Blume site in docs-site/ with an API reference at /api and a first-request tutorial next to it. A CI job regenerates the schema on every pull request and fails when the committed copy is out of date. The example is the fictional Acme Messages API, which sends email and SMS.

If your team only needs an interactive explorer, drf-spectacular already serves Swagger UI and Redoc views from your Django app, with no second project. Blume fits when the reference should live with written guides on a docs site you host as static files. DRF's own schema generator is deprecated in favor of drf-spectacular, so this guide doesn't use it. For how Blume's reference works with any spec, see Generate API docs from an OpenAPI spec.

Install drf-spectacular

Pin the three packages:

Django==6.0.8
djangorestframework==3.17.2
drf-spectacular==0.30.0

drf-spectacular 0.30.0 lists support for Django up to 6.0 and DRF up to 3.17, so these pins stay in that range even though newer releases of both are out. Django 6.0 needs Python 3.12 or later. Install them, and if you're starting from scratch, create the project and an app for the API:

python -m pip install -r requirements.txt
django-admin startproject acme .
python manage.py startapp messaging

Add the apps and settings to the end of the settings file:

INSTALLED_APPS += [
    "rest_framework",
    "rest_framework.authtoken",
    "drf_spectacular",
    "messaging",
]

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "messaging.authentication.BearerTokenAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
    "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}

SPECTACULAR_SETTINGS = {
    "TITLE": "Acme Messages API",
    "DESCRIPTION": "Send transactional email and SMS, and manage the templates they use.",
    "VERSION": "1.0.0",
    "SERVERS": [{"url": "https://api.acme.example/v1"}],
    "SCHEMA_PATH_PREFIX": r"/v1",
    "SCHEMA_PATH_PREFIX_TRIM": True,
    "COMPONENT_SPLIT_REQUEST": True,
    "TAGS": [
        {"name": "Messages", "description": "Send a message and check whether it arrived."},
        {"name": "Templates", "description": "Reusable message bodies with variables."},
    ],
}

Each part changes what the reference shows:

  • Authentication. drf-spectacular documents every authentication class you list, and DRF's defaults are session and basic auth. Listing only your API's real scheme keeps the reference to the one readers use.
  • Pagination. List endpoints get a page query parameter and a response wrapped in count, next, previous, and results.
  • Servers and path prefix. Django serves the API under /v1/, and the server URL already ends in /v1, so SCHEMA_PATH_PREFIX_TRIM drops it from each path. Operations read /messages, not /v1/messages.
  • Split request components. COMPONENT_SPLIT_REQUEST writes a separate request schema without the read-only fields. Blume's schema tables list every property they're given, so without it, id and status would show up in the request body.
  • Tags. Their descriptions become the section intros on the reference's overview page.

Leave OAS_VERSION unset. drf-spectacular writes OpenAPI 3.0.3 by default, which Blume upgrades to 3.1 as it reads the file. It can also write 3.2.0, which Blume doesn't convert.

Accept bearer tokens

DRF's TokenAuthentication expects Authorization: Token <key>. Subclass it to accept the more common Bearer keyword:

from rest_framework.authentication import TokenAuthentication


class BearerTokenAuthentication(TokenAuthentication):
    keyword = "Bearer"

With that keyword, drf-spectacular describes the class as an HTTP bearer scheme. Blume then shows an Authorization section on each operation and sends Authorization: Bearer YOUR_TOKEN in its code samples. The class still reads DRF's token table, so keep rest_framework.authtoken installed and run python manage.py migrate. To test locally, python manage.py drf_create_token ada prints a key for the user ada.

Describe the API in code

drf-spectacular reads your models, serializers, and views, so this is where the reference's content comes from. Start with the models:

import secrets

from django.db import models


def new_message_id():
    return f"msg_{secrets.token_hex(4)}"


class Template(models.Model):
    id = models.SlugField(primary_key=True, help_text="The template ID, like welcome.")
    name = models.CharField(max_length=200)
    body = models.TextField(help_text="The message body, with {{ variables }}.")


class Message(models.Model):
    class Channel(models.TextChoices):
        EMAIL = "email"
        SMS = "sms"

    class Status(models.TextChoices):
        QUEUED = "queued"
        SENT = "sent"
        DELIVERED = "delivered"
        FAILED = "failed"

    id = models.CharField(
        primary_key=True,
        max_length=20,
        default=new_message_id,
        editable=False,
        help_text="The message ID, like msg_5f2a9c1e.",
    )
    channel = models.CharField(max_length=5, choices=Channel.choices)
    to = models.CharField(max_length=254)
    template = models.ForeignKey(Template, on_delete=models.PROTECT)
    variables = models.JSONField(default=dict, blank=True)
    status = models.CharField(
        max_length=9, choices=Status.choices, default=Status.QUEUED
    )
    created_at = models.DateTimeField(auto_now_add=True)

Run python manage.py makemigrations messaging and migrate, then add the serializers:

import re

from drf_spectacular.utils import OpenApiExample, extend_schema_serializer
from rest_framework import serializers

from .models import Message, Template


@extend_schema_serializer(
    examples=[
        OpenApiExample(
            "Welcome email",
            value={
                "channel": "email",
                "to": "ada@example.com",
                "template": "welcome",
                "variables": {"name": "Ada"},
            },
            request_only=True,
        ),
        OpenApiExample(
            "Queued message",
            value={
                "id": "msg_5f2a9c1e",
                "channel": "email",
                "to": "ada@example.com",
                "template": "welcome",
                "variables": {"name": "Ada"},
                "status": "queued",
                "created_at": "2026-09-27T09:30:00Z",
            },
            response_only=True,
            status_codes=[200, 202],
        ),
    ]
)
class MessageSerializer(serializers.ModelSerializer):
    variables = serializers.DictField(
        child=serializers.CharField(),
        required=False,
        help_text="Values for the template's variables.",
    )

    class Meta:
        model = Message
        fields = ["id", "channel", "to", "template", "variables", "status", "created_at"]
        read_only_fields = ["id", "status", "created_at"]
        extra_kwargs = {
            "channel": {"help_text": "How to deliver the message."},
            "to": {"help_text": "An email address, or a phone number in E.164 format."},
            "template": {"help_text": "The ID of the template to send."},
        }


class TemplateSerializer(serializers.ModelSerializer):
    variables = serializers.SerializerMethodField()

    class Meta:
        model = Template
        fields = ["id", "name", "body", "variables"]

    def get_variables(self, template):
        return re.findall(r"{{\s*(\w+)\s*}}", template.body)

Three choices here shape the reference:

  • help_text becomes each field's description in the schema tables.
  • variables is declared as a DictField of strings. Left to the model's JSONField, drf-spectacular writes an empty schema, and the table can only call it any.
  • extend_schema_serializer adds examples. The request example fills in the Try it form, and the response example replaces generated values like "string". Response examples only attach to 200 and 201 responses unless you set status_codes, and sending a message returns 202.

Then the views and URLs:

from drf_spectacular.utils import (
    OpenApiResponse,
    extend_schema,
    extend_schema_view,
)
from rest_framework import mixins, status, viewsets

from .models import Message, Template
from .serializers import MessageSerializer, TemplateSerializer


@extend_schema(tags=["Messages"])
@extend_schema_view(
    create=extend_schema(
        operation_id="sendMessage",
        summary="Send a message",
        description="Queues an email or SMS for delivery and returns its ID right away.",
        responses={
            202: OpenApiResponse(MessageSerializer, description="The message is queued."),
        },
    ),
    retrieve=extend_schema(
        operation_id="getMessage",
        summary="Get a message",
        description="Returns a message and its delivery status.",
        responses={
            200: OpenApiResponse(MessageSerializer, description="The message."),
            404: OpenApiResponse(description="No message has that ID."),
        },
    ),
    list=extend_schema(
        operation_id="listMessages",
        summary="List messages",
        description="Returns your messages, newest first, 20 per page.",
    ),
)
class MessageViewSet(
    mixins.CreateModelMixin,
    mixins.RetrieveModelMixin,
    mixins.ListModelMixin,
    viewsets.GenericViewSet,
):
    queryset = Message.objects.order_by("-created_at")
    serializer_class = MessageSerializer

    def create(self, request, *args, **kwargs):
        response = super().create(request, *args, **kwargs)
        response.status_code = status.HTTP_202_ACCEPTED
        return response


@extend_schema(tags=["Templates"])
@extend_schema_view(
    list=extend_schema(
        operation_id="listTemplates",
        summary="List templates",
        description="Returns every template in the workspace, 20 per page.",
    ),
)
class TemplateViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
    queryset = Template.objects.order_by("id")
    serializer_class = TemplateSerializer
from django.urls import include, path
from rest_framework.routers import SimpleRouter

from messaging.views import MessageViewSet, TemplateViewSet

router = SimpleRouter(trailing_slash=False)
router.register("messages", MessageViewSet, basename="message")
router.register("templates", TemplateViewSet, basename="template")

urlpatterns = [
    path("v1/", include(router.urls)),
]

Blume builds each operation's URL from its first tag and its operation ID. Left alone, drf-spectacular names operations after the route and action, like messages_create, and tags them messages, so the page would live at /api/messages/messages-create. With operation_id and tags set, the pages are:

OperationPage
POST /messages/api/messages/send-message
GET /messages/{id}/api/messages/get-message
GET /messages/api/messages/list-messages
GET /templates/api/templates/list-templates

summary becomes the page title and sidebar label, and description the page's introduction. The create override returns 202, and drf-spectacular can't see a status code set inside a method, so responses declares it. trailing_slash=False keeps paths as /messages rather than /messages/.

Generate and validate the schema

Create the folder the docs site will live in, then write the schema into it from the repository root:

mkdir docs-site
python manage.py spectacular --file docs-site/openapi.yaml --validate --fail-on-warn

--validate checks the output against the OpenAPI JSON Schema, and --fail-on-warn exits with an error on any warning. This first run fails with one warning (your output starts with the file's full path):

messaging/serializers.py:55: Warning [TemplateViewSet > TemplateSerializer]: unable to resolve type hint for function "get_variables". Consider using a type hint or @extend_schema_field. Defaulting to string.

Schema generation summary:
Warnings: 1 (1 unique)
Errors:   0 (0 unique)

SchemaGenerationError: Failing as requested due to warnings

A SerializerMethodField can return anything, so drf-spectacular reads the method's return type hint. There isn't one, so it falls back to string, and the reference would call a list a string. Because the run failed, it didn't write the file: with --fail-on-warn, a failed run leaves the previous schema untouched.

Fix the warning

Declare the field's type with extend_schema_field. Change the import at the top of the serializers file and decorate the method:

from drf_spectacular.utils import (
    OpenApiExample,
    extend_schema_field,
    extend_schema_serializer,
)

# ...


class TemplateSerializer(serializers.ModelSerializer):
    variables = serializers.SerializerMethodField()

    class Meta:
        model = Template
        fields = ["id", "name", "body", "variables"]

    @extend_schema_field(serializers.ListField(child=serializers.CharField()))
    def get_variables(self, template):
        return re.findall(r"{{\s*(\w+)\s*}}", template.body)

A return type hint, -> list[str], fixes it too. Run the command again: it exits cleanly and writes docs-site/openapi.yaml.

Shape the schema for the docs

The schema is now valid, but two defaults read poorly in a reference. Both are fixed on the Django side.

Keep choice fields inline

Look at channel in the generated file. drf-spectacular moves every choice field into a shared enum component, and when the field has its own description, it wraps the reference in allOf. Blume's schema tables label an allOf field as object and don't list its allowed values, so channel and status would read as objects.

The enum components come from a post-processing hook. Turn it off for the docs only, so a schema you serve or generate clients from keeps them. Put the overrides in their own module:

# Overrides for the schema the docs site reads. Pass them with
# manage.py spectacular --custom-settings acme.docs_schema.SETTINGS
SETTINGS = {
    # Keep choice fields inline instead of moving them to shared
    # enum components, and leave their allowed values out of the
    # description, since the reference lists them.
    "POSTPROCESSING_HOOKS": [],
    "ENUM_GENERATE_CHOICE_DESCRIPTION": False,
}

--custom-settings applies them on top of SPECTACULAR_SETTINGS for one run:

python manage.py spectacular --custom-settings acme.docs_schema.SETTINGS \
  --file docs-site/openapi.yaml --validate --fail-on-warn

Now channel is a string with email and sms as its allowed values. Each inline enum keeps an x-spec-enum-id marker the hook would have removed; Blume ignores it.

Replace the pagination examples

DRF's PageNumberPagination describes its response with example links to http://api.example.org/accounts/, and those end up in every list example. Subclass it to describe a single page instead:

from rest_framework.pagination import PageNumberPagination


class AcmePagination(PageNumberPagination):
    def get_paginated_response_schema(self, schema):
        response = super().get_paginated_response_schema(schema)
        # Describe a first page that holds every result.
        response["properties"]["count"]["example"] = 1
        response["properties"]["next"]["example"] = None
        response["properties"]["previous"]["example"] = None
        return response

Set DEFAULT_PAGINATION_CLASS to "messaging.pagination.AcmePagination" and run the command again. Then commit docs-site/openapi.yaml: the docs build reads the committed file, and its diff in each pull request shows reviewers what changed in the API.

Create the docs site

Scaffold Blume into the folder that holds the schema. Blume needs Node.js 22.12 or later.

npx blume init docs-site --template docs --yes

It adds a blume.config.ts, a docs/index.mdx home page, and a package.json with dev and build scripts, installs Blume, and leaves openapi.yaml alone. Replace the config to mount the reference and give it a header tab:

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" })],
});

The spec path is relative to docs-site/. The reference never adds a tab on its own, so the tab pointing at /api is what makes it reachable. Start the dev server:

cd docs-site
npm run dev

The API reference tab opens an overview at /api with a section for Messages and one for Templates. Send a message has an Authorization section for the bearer token, a request body without the read-only fields, and the 202 response with its example. The list pages show the page parameter and the paginated response.

The Try it panel on each page sends requests from the reader's browser to the server in the schema, so a real API has to allow the docs site's origin with CORS. In Django that's usually the django-cors-headers package; see Fix CORS errors in an API documentation playground.

Write a first-request tutorial

The reference says what each endpoint takes. New users also need the path from an API key to a delivered message, and that's 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: Make your first request
description: Send an email with the Acme Messages API, check its status, and page through your messages.
---

Every request needs an API key, sent in the `Authorization` header as a
bearer token. Keep yours in an environment variable so the commands below
can read it:

```bash
export ACME_API_KEY="paste-your-key-here"
```

## Send a message

[Send a message](/api/messages/send-message) queues an email from the
`welcome` template and fills in its `name` variable:

```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", "variables": {"name": "Ada"}}'
```

The API answers `202 Accepted` before the email goes out, with the
message and its ID:

```json
{
  "id": "msg_5f2a9c1e",
  "channel": "email",
  "to": "ada@example.com",
  "template": "welcome",
  "variables": { "name": "Ada" },
  "status": "queued",
  "created_at": "2026-09-27T09:30:00Z"
}
```

## Check its status

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

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

`status` is one of `queued`, `sent`, `delivered`, or `failed`.

## Page through your messages

[List messages](/api/messages/list-messages) returns 20 messages per page,
newest first, inside `count`, `next`, `previous`, and `results`.
Request the URL in `next` until it's `null`, rather than building page
numbers yourself.

## When a request fails

- `401 Unauthorized`: the key is missing or invalid, or the header
  doesn't start with `Bearer`.
- `400 Bad Request`: the body is an object keyed by field name, each with
  a list of problems. Fix those fields and send again.
- `404 Not Found` from Get a message: no message has that ID.

The page lives at /api/first-request and links to operation pages by URL, like any other page. Link the other way too: operation descriptions render as Markdown, so ending the sendMessage description with [Make your first request](/api/first-request) puts the tutorial one click from the reference.

Build and check

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

npm run build
npx blume validate
npx blume preview

The build fails if it can't read the spec. blume validate checks every internal link, including the tutorial's links to operation pages, so a renamed operation ID shows up as a broken link instead of a 404 in production. In the preview, open /api/messages/send-message directly and search for "send a message" to confirm the page and its search entry. The build is static files in dist/ that any static host can serve; see Deployment.

Rebuild the docs from code in CI

The committed schema is only as current as the last time someone ran the command. This workflow regenerates it on every pull request, fails if the committed copy differs, and builds the site:

name: API docs

on:
  pull_request:
  push:
    branches: [main]

jobs:
  docs:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v7
        with:
          python-version: "3.13"
      - run: python -m pip install -r requirements.txt
      - name: Generate the schema
        run: >-
          python manage.py spectacular
          --custom-settings acme.docs_schema.SETTINGS
          --file docs-site/openapi.yaml --validate --fail-on-warn
      - name: Fail if the committed schema is out of date
        run: git diff --exit-code -- docs-site/openapi.yaml
      - uses: actions/setup-node@v7
        with:
          node-version: 24
      - run: npm ci
        working-directory: docs-site
      - run: npx blume validate
        working-directory: docs-site
      - run: npm run build
        working-directory: docs-site

Generating the schema imports your settings and URL configuration. In this example it needs no database, so the job only installs Python dependencies; if your settings read environment variables at import, set them on that step. drf-spectacular sorts its output, so the same code writes the same file, and git diff --exit-code fails only on a real change. When it fails, run the command locally and commit the result. For gating breaking API changes as well, see Keep API documentation in sync with backend changes in CI.

Troubleshooting

Generation fails on an APIView

An APIView with no serializer_class logs unable to guess serializer as an error, and --fail-on-warn fails on errors too. Describe the view with @extend_schema(request=..., responses=...) on its method, or base it on GenericAPIView with a serializer.

Code samples send a sessionid cookie

With DRF's default authentication classes, every operation lists cookieAuth first and basicAuth second. Blume shows both as alternatives, and its code samples use the first. Set DEFAULT_AUTHENTICATION_CLASSES to the scheme your API clients actually use.

The samples leave out the Token prefix

With the stock TokenAuthentication, drf-spectacular can't express Token <key> as an HTTP scheme, so it writes an API key in the Authorization header, with a description that names the prefix. Blume's samples then send Authorization: YOUR_API_KEY. Switch to the Bearer keyword as shown above, or say in your tutorial that the key needs the prefix.

Every operation says authentication is optional

AllowAny, or IsAuthenticatedOrReadOnly on a read, adds an empty requirement to the operation's security, which Blume shows as optional authentication. That's accurate if anonymous calls work. If they shouldn't, fix the view's permission classes.

Sample requests go to /v1/v1/messages

The paths still start with /v1 while the server URL ends in it. Set SCHEMA_PATH_PREFIX to your URL prefix and SCHEMA_PATH_PREFIX_TRIM to True.

The reference doesn't change after you regenerate

blume dev doesn't watch the spec file, so restart it. If blume build fails with BLUME_OPENAPI_UNAVAILABLE, check that spec is relative to docs-site/, like ./openapi.yaml.

Next step

Let readers send real requests

Allow the docs origin in your Django API, or turn on Blume's proxy, so the Try it panel reaches your API from the browser.

Read the CORS guide

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

Keep going.More guides.

  • Document Kafka events with AsyncAPI

    Describe your Kafka topics in AsyncAPI, publish event-driven API documentation with a page per operation, and pair it with producer and consumer code you've run.

Upgrade your docs with Blume.

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

npx blume init