Docusaurus for API Portals: Setup, OpenAPI Integration & CI

Docusaurus v3 gives platform teams a React-based static-site foundation that doubles as an interactive API reference. This guide is part of Developer Portal Frameworks & UI Setup, and it focuses specifically on running Docusaurus as a spec-driven portal: wiring docusaurus-plugin-openapi-docs, generating MDX from an OpenAPI document, versioning the reference, and keeping everything synchronized through CI. It does not cover generic Docusaurus blogging features or non-API content systems — for those, the upstream framework docs are sufficient.

The work that actually breaks builds is rarely the framework itself. It is spec drift, plugin/peer-dependency mismatches across Docusaurus major versions, and stale generated MDX checked into the repository. The patterns below treat the OpenAPI document as the source of truth and regenerate the reference deterministically on every change, so the portal never lags behind the contract.

Docusaurus API portal pipeline An OpenAPI spec is linted, then gen-api-docs emits MDX, the theme renders it, and the static build deploys. openapi.yaml source of truth redocly lint fail fast gen-api-docs MDX per op docusaurus build deploy Spec-to-portal pipeline

Quick reference: what Docusaurus gives you, and what it costs

Before committing, weigh the capabilities against the pipeline you inherit. The left column is what teams pick Docusaurus for; the right is the standing maintenance each capability implies:

Capability How you get it Ongoing cost
Reference + guides in one site docs/ tree with generated MDX alongside hand-written pages Sidebar config drifts as tags change
Interactive try-it console docusaurus-theme-openapi-docs ApiItem component CORS + credential handling on the API side
Versioned references docusaurus docs:version <label> snapshots the tree Repo size grows per version; old versions need pruning
Full theming Infima CSS variables, then selective swizzling Swizzled components must be re-checked on every theme upgrade
Full-site search DocSearch theme reads appId/indexName Index must be rebuilt after each deploy
Self-hosted output npm run build produces plain static files You own the CI workflow and the host
MIT licence, no vendor Open-source plugin + framework Plugin majors track framework majors closely

The row that surprises teams is the last one. docusaurus-plugin-openapi-docs is maintained separately from Docusaurus itself, so a framework major can land weeks before the plugin supports it. Pin both packages and upgrade them together, deliberately, rather than letting a ^ range decide.

Prerequisites & Environment Setup

Pin versions deliberately — docusaurus-plugin-openapi-docs tracks Docusaurus majors closely, and an unpinned upgrade is the single most common cause of a broken regeneration.

  • Node.js 18.0+ (Docusaurus v3 dropped Node 16). Node 20 LTS is the safe baseline.
  • A Docusaurus v3 site scaffolded with the classic preset.
  • An OpenAPI 3.0.x or 3.1.x document. Swagger 2.0 must be converted first.
# Scaffold a fresh site if you do not have one
npx create-docusaurus@latest portal classic --typescript

cd portal

# Install the OpenAPI plugin + its rendering theme, pinned
npm install docusaurus-plugin-openapi-docs@^4.4.0 \
            docusaurus-theme-openapi-docs@^4.4.0

# Validate the spec toolchain up front
npx @redocly/cli@latest --version   # expect 2.x

Place the spec under a stable directory the plugin can find:

portal/
├─ openapi/
│  └─ api.yaml          # root spec; external $ref files live alongside
├─ docs/                # hand-written guides + generated api/ output
├─ docusaurus.config.js
└─ sidebars.js

If you are converting from Swagger 2.0, run the conversion once and commit the OpenAPI output rather than converting in CI:

npx swagger2openapi swagger.yaml -o openapi/api.yaml

Core Configuration

The plugin reads the spec and emits MDX; the theme supplies the React components that render those MDX files (request builder, schema tables, try-it-out panel). Both must be registered in docusaurus.config.js. Every non-obvious key is annotated inline.

// docusaurus.config.js
// @ts-check
/** @type {import('@docusaurus/types').Config} */
module.exports = {
  title: 'API Portal',
  url: 'https://docs.example.com',
  baseUrl: '/',                       // MUST match the deploy subdirectory or version routes 404

  presets: [
    [
      'classic',
      /** @type {import('@docusaurus/preset-classic').Options} */
      ({
        docs: {
          // The OpenAPI theme replaces the default doc item renderer
          docItemComponent: '@theme/ApiItem',
        },
      }),
    ],
  ],

  plugins: [
    [
      'docusaurus-plugin-openapi-docs',
      {
        id: 'api',                    // plugin instance id, referenced by gen-api-docs
        docsPluginId: 'classic',      // which docs instance the output belongs to
        config: {
          petstore: {                 // an arbitrary key naming this spec
            specPath: 'openapi/api.yaml',
            outputDir: 'docs/api',    // generated MDX lands here
            sidebarOptions: {
              groupPathsBy: 'tag',    // one sidebar category per OpenAPI tag
              categoryLinkSource: 'tag',
            },
            downloadUrl: '/openapi/api.yaml',  // exposes the raw spec for download
            hideSendButton: false,    // keep try-it-out enabled
          },
        },
      },
    ],
  ],

  themes: ['docusaurus-theme-openapi-docs'],
};

The two packages have distinct jobs, and confusing them causes most “the page renders but the schema table is missing” reports. The plugin is a build-time code generator: it reads the spec once and writes MDX files to disk. The theme is a set of React components that those MDX files import at render time. Installing one without the other produces either MDX nobody can render, or renderers with nothing to render:

Plugin versus theme responsibilities The plugin runs at generation time turning the spec into MDX files; the theme runs at render time turning those MDX files into interactive components. generation time — the plugin openapi/api.yaml docs/api/*.mdx one per operation gen-api-docs — runs in CI output is disposable, gitignored render time — the theme @theme/ApiItem schema tables try-it console docItemComponent override ships in the built bundle

Generated MDX should be treated as build output. Add docs/api/ to .gitignore and regenerate it in CI rather than committing it — committed output is the second most common source of drift after unpinned plugins.

# Generate one MDX file per operation, grouped by tag
npm run docusaurus gen-api-docs api

# Wipe generated output before a clean regeneration
npm run docusaurus clean-api-docs api

Reference external $ref files from the root document to keep api.yaml readable. The plugin resolves them at generation time, so a missing target fails gen-api-docs rather than the later build — fail-fast is the goal.

Integration Pattern

The portal should rebuild deterministically whenever the spec or the hand-written guides change. The workflow below lints the contract, regenerates the reference, builds the static site, and deploys to GitHub Pages. It fails fast: a lint violation stops the run before any broken MDX is produced. This is the same fail-fast posture used across the Redocly & OpenAPI UI Configuration guide.

# .github/workflows/portal-build.yml
name: Build API Portal
on:
  push:
    branches: [main]
    paths: ['openapi/**', 'docs/**', 'docusaurus.config.js']
permissions:
  contents: write          # required for the gh-pages deploy step
jobs:
  validate-and-build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - name: Install dependencies
        run: npm ci
      - name: Validate OpenAPI            # fail fast on a malformed contract
        run: npx @redocly/cli@latest lint openapi/api.yaml
      - name: Regenerate API docs         # deterministic output, not committed
        run: |
          npm run docusaurus clean-api-docs api
          npm run docusaurus gen-api-docs api
      - name: Build site
        run: npm run build
      - name: Deploy to GitHub Pages
        if: github.ref == 'refs/heads/main'
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./build

For a deeper, end-to-end walkthrough of binding the spec — including multi-spec setups and per-tag sidebar tuning — see Integrating Docusaurus with OpenAPI Specs.

Advanced Options

Versioned API references

Run the versioning command to snapshot the current docs/ tree into versioned_docs/version-<label>/. Docusaurus then routes each version under its own prefix and renders a version dropdown automatically.

npm run docusaurus docs:version 2.0

The sidebar for each version lives in versioned_sidebars/version-2.0-sidebars.json:

{
  "tutorialSidebar": [
    {
      "type": "category",
      "label": "API Reference",
      "items": ["api/overview", "api/users", "api/orders"]
    }
  ]
}

Docusaurus manages version routing itself — do not hand-roll a versioned_sidebars.js that reads versions.json at runtime. Regenerate API MDX into the live (unversioned) tree, then cut a version only on a breaking release.

The mental model that keeps versioning manageable is that docs/ is mutable current and versioned_docs/ is immutable history. Generation always targets the mutable tree; the versioning command copies it once, at a moment you choose, and nothing regenerates into a frozen version afterwards. Teams that instead regenerate into each versioned tree end up rewriting history every time the plugin changes its MDX output format:

Current tree versus versioned snapshots Generation always writes into the current docs tree; the versioning command copies it into an immutable snapshot that is never regenerated. gen-api-docs every build docs/ (current) mutable — /docs/api/ version-2.0 frozen /docs/2.0/api/ version-1.0 frozen /docs/1.0/api/ docs:version — copies once, on a breaking release snapshots are never regenerated

Prune aggressively. Each snapshot is a full copy of the tree, so a portal that has cut a version per quarter for two years carries eight complete references in the repository and rebuilds all of them on every deploy. Keep the versions you actually support, redirect the rest to the oldest supported one, and delete the directories. Versioned API docs in Docusaurus covers the cutting, redirecting, and pruning cycle in full.

Theming with CSS variables and selective swizzling

Theming is layered: global CSS custom properties (Infima --ifm-* variables) control most of the palette, and component swizzling replaces individual React components only when variables are insufficient.

/* src/css/custom.css */
:root {
  --ifm-color-primary: #16306d;
  --ifm-font-family-monospace: 'JetBrains Mono', monospace;
}
[data-theme='dark'] {
  --ifm-color-primary: #4c9aff;
  --ifm-background-color: #0b1120;
}

Keep dark-mode parity for the reference UI itself; the token strategy is covered in depth in Multi-Theme & Dark Mode Support. Swizzle the OpenAPI theme’s components with npm run swizzle docusaurus-theme-openapi-docs <Component> -- --eject only when a CSS variable cannot achieve the result, and validate every override against WCAG 2.1 AA contrast (4.5:1 for normal text).

Splitting large specs for faster regeneration

Split a monolithic spec into per-resource files referenced by $ref and commit the fragments alongside the root. The plugin only re-emits MDX for operations whose source changed, so a focused edit regenerates a handful of files instead of the whole reference.

The split also changes how review works, which is usually the bigger win. A single 12,000-line api.yaml produces pull request diffs nobody can read, and merge conflicts on every concurrent change because every team edits the same file. Per-resource fragments give each team its own file, make the diff for “added a field to Invoice” one screen long, and let CODEOWNERS route review to the team that owns that resource. The cost is one extra bundling step before anything that expects a single document — the renderer, a diff tool, an SDK generator — which is exactly what the bundle command in the workflow above does.

Serving multiple specs from one portal

A platform team usually owns several APIs rather than one. The plugin accepts multiple named entries under config, each with its own spec path and output directory, and gen-api-docs can target them individually or all at once:

config: {
  billing: {
    specPath: 'openapi/billing.yaml',
    outputDir: 'docs/api/billing',
    sidebarOptions: { groupPathsBy: 'tag' },
  },
  identity: {
    specPath: 'openapi/identity.yaml',
    outputDir: 'docs/api/identity',
    sidebarOptions: { groupPathsBy: 'tag' },
  },
}
npm run docusaurus gen-api-docs billing    # one spec
npm run docusaurus gen-api-docs all        # every configured spec

Regenerating only the spec that changed keeps CI fast once you are past a handful of APIs. Scope the workflow’s paths filter per spec if the specs live in the same repository, so a billing change does not rebuild the identity reference.

Gating access without a server

Because the build output is plain static files, there is no application layer to authenticate against — a fact that surprises teams migrating from a server-rendered portal. Authentication belongs at the edge: an identity-aware proxy in front of the origin, signed URLs at the CDN, or a reverse-proxy auth_request handler. All three leave the Docusaurus build untouched, which is the point. Resist the temptation to hide content behind client-side checks; the MDX is in the bundle regardless of what the UI renders, so anything genuinely confidential must not be in the build at all. Split truly internal operations into a separate spec and a separate, separately gated build.

Verification & Testing

Confirm the pipeline locally before trusting CI:

# 1. Lint the contract — expect "No errors or warnings found"
npx @redocly/cli@latest lint openapi/api.yaml

# 2. Regenerate and count emitted files
npm run docusaurus gen-api-docs api
ls docs/api | wc -l          # one .mdx per operation + category metadata

# 3. Build and check for broken links (Docusaurus fails the build on broken links)
npm run build                # onBrokenLinks defaults to 'throw'

# 4. Serve the production build and spot-check try-it-out
npm run serve                # http://localhost:3000

A clean run prints [SUCCESS] Generated static files in "build". with no broken-link warnings. In the served site, open a tagged operation, expand its schema table, and execute a try-it-out request against a reachable endpoint to confirm the theme components hydrated.

Troubleshooting

gen-api-docs fails with “Could not resolve reference” — A $ref in api.yaml points to a missing or mistyped file. Run npx @redocly/cli lint openapi/api.yaml first; it reports the exact unresolved pointer and line. Fix the path relative to the root document, not relative to the referencing fragment.

Version routes return 404 in productionbaseUrl in docusaurus.config.js does not match the deploy subdirectory. If the site is served from https://docs.example.com/portal/, set baseUrl: '/portal/'. A mismatch breaks every versioned and asset URL even though local npm run serve works.

Plugin API errors after a Docusaurus upgrade — A major Docusaurus bump deprecated a plugin API while docusaurus-plugin-openapi-docs and docusaurus-theme-openapi-docs lag behind. Keep both packages pinned to the same minor line and matching the Docusaurus major; check the plugin changelog for the required peer-dependency range before upgrading either.

Stale reference served after a spec change — Generated MDX was committed and never regenerated, or a CDN cached index.html. Remove docs/api/ from version control, regenerate in CI, and set Cache-Control: no-cache on index.html while keeping Docusaurus’s content-hashed asset filenames for everything else.

Build fails with “Docs markdown link couldn’t be resolved” — A hand-written guide links to a generated operation page whose slug changed because the operationId or tag was renamed in the spec. Docusaurus treats broken internal links as build errors by default, which is the right setting: it turns a silent 404 into a failed pipeline. Fix the link rather than lowering onBrokenLinks to warn, and prefer linking to a tag category page over a specific operation when the exact operation is likely to be renamed.

The try-it console works locally but fails in production — The browser is calling the real API directly from the docs origin, and the API’s CORS policy allows localhost but not the docs domain. This is not a Docusaurus problem and cannot be fixed in the theme; either add the docs origin to the API’s Access-Control-Allow-Origin list, or route console requests through a proxy on the docs domain.

Sidebar categories vanish after a spec editgroupPathsBy: 'tag' builds categories from the spec’s tags array, so an operation that lost its tag falls out of every category and lands at the tree root. Make a tag required in your spec lint ruleset so the portal’s navigation cannot be broken by a spec change that passes schema validation.

A useful habit for all of these: run the full generate-and-build locally before pushing a spec change, not just the lint. Linting proves the document is valid OpenAPI; it says nothing about whether the plugin can turn it into a navigable site. The two-command check npm run docusaurus clean-api-docs api && npm run docusaurus gen-api-docs api && npm run build catches slug collisions, missing tags, and unresolvable links in about the time it takes to read the diff.

FAQ

How does Docusaurus handle multiple API versions?

Docusaurus snapshots the entire docs/ tree when you run npm run docusaurus docs:version <label>, giving each version an isolated URL prefix such as /docs/2.0/api/. You can host legacy and current references in parallel and cut a new version on every breaking release.

Can I use Docusaurus without OpenAPI specs?

Yes. Docusaurus renders standard Markdown and MDX for conceptual guides, tutorials, and architecture pages. The OpenAPI plugin is additive and is not required to run a Docusaurus site.

Why does gen-api-docs fail with a $ref resolution error?

The plugin resolves external $ref pointers at generation time, so a missing or mistyped file path halts the build. Run a lint pass over the bundled spec first and confirm every $ref target exists relative to the root document.

Is Docusaurus suitable for enterprise SSO portals?

Docusaurus produces a static site, so authentication is enforced at the hosting or reverse-proxy layer rather than inside the app. Front the build with Cloudflare Access, signed CloudFront URLs, or an Nginx auth_request handler to gate access.