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

API reference

Combine multiple OpenAPI specifications in one docs site

An API portal with a reference per service: each OpenAPI spec on its own route and tab, and shared operation names kept apart in URLs, sidebars, and search.

By 11 min read

To publish several OpenAPI specs in one docs site, list each spec as a source of Blume's openapi() reference and give each one its own route, such as /api/messages and /api/contacts. An operation's URL is built from its spec's route, its tag, and its operation ID, so two services can both define listEvents without their pages colliding. Then point navigation at those routes.

By the end, you have an API documentation portal for two services: a reference per spec, a header menu and landing page that lead to each one, page titles that say which API they belong to, and a check that tells you when two specs claim the same URL. The example is two fictional Acme services that both expose GET /events with the same operation ID, summary, and tag.

This guide is about the specs. With one spec, Generate API docs from an OpenAPI spec covers everything. If you're organizing whole products, each with its own guides and SDKs as well as an API, start with Build a developer portal for multiple products and come back here for the reference setup. And if your services already publish one merged spec through a gateway, mount that single file instead.

Add the specs

Start from a Blume project (npx blume init, docs template) and keep each service's spec as its own file. Blume reads every spec at build time. A spec can also be an http(s) URL, but a local copy means a build never depends on another service being reachable.

openapi: 3.1.0
info:
  title: Acme Messages API
  version: 1.0.0
  description: Send transactional email and SMS, and track their delivery.
servers:
  - url: https://api.acme.example/v1
security:
  - bearerAuth: []
tags:
  - name: Messages
    description: Send a message and check whether it arrived.
  - name: Events
    description: Delivery events for the messages you send.
paths:
  /messages:
    post:
      operationId: sendMessage
      tags: [Messages]
      summary: Send a message
      description: Queues an email or SMS for delivery and returns its ID.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [channel, to, template]
              properties:
                channel:
                  type: string
                  enum: [email, sms]
                to:
                  type: string
                template:
                  type: string
      responses:
        "202":
          description: The message is queued.
  /messages/{id}:
    get:
      operationId: getMessage
      tags: [Messages]
      summary: Get a message
      description: Returns a message and its delivery status.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The message.
  /events:
    get:
      operationId: listEvents
      tags: [Events]
      summary: List events
      description: Returns delivery events for your messages, newest first.
      responses:
        "200":
          description: A page of delivery events.
  /events/{id}:
    get:
      operationId: getEvent
      tags: [Events]
      summary: Get an event
      description: Returns one delivery event.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The delivery event.
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
openapi: 3.1.0
info:
  title: Acme Contacts API
  version: 1.0.0
  description: Manage the people you message, and who has opted out.
servers:
  - url: https://contacts.acme.example/v1
security:
  - bearerAuth: []
tags:
  - name: Contacts
    description: Create and look up contacts.
  - name: Events
    description: Changes to contacts, such as unsubscribes.
paths:
  /contacts:
    post:
      operationId: createContact
      tags: [Contacts]
      summary: Create a contact
      description: Adds a contact with an email address, a phone number, or both.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email:
                  type: string
                phone:
                  type: string
      responses:
        "201":
          description: The contact was created.
  /contacts/{id}:
    get:
      operationId: getContact
      tags: [Contacts]
      summary: Get a contact
      description: Returns a contact and its subscription status.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The contact.
  /events:
    get:
      operationId: listEvents
      tags: [Events]
      summary: List events
      description: Returns contact events, such as unsubscribes, newest first.
      responses:
        "200":
          description: A page of contact events.
  /events/{id}:
    get:
      operationId: getEvent
      tags: [Events]
      summary: Get an event
      description: Returns one contact event.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The contact event.
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer

Both specs define GET /events and GET /events/{id} with the same operation IDs, summaries, and tag. That's common when services start from one template, and it's the case the rest of this guide handles. Neither server exists, so point servers at your real APIs when you use your own specs.

Mount each spec at its own route

List both specs under sources in one openapi() adapter, each with a route:

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

export default defineConfig({
  title: "Acme Docs",
  reference: [
    openapi({
      sources: [
        {
          label: "Messages API",
          route: "/api/messages",
          spec: "./specs/messages.yaml",
        },
        {
          label: "Contacts API",
          route: "/api/contacts",
          spec: "./specs/contacts.yaml",
        },
      ],
    }),
  ],
});

Each source gets an overview page at its route, titled with the spec's info.title, and a page per operation below it. The sources share the adapter's display options, like codeSamples and playground.

Set route on every source. Without it, several sources mount below the adapter's route (/reference by default) at a slug of each label, like /reference/messages-api, or at /reference/1 and /reference/2 when there's no label. An explicit route means renaming a label never moves a page. Two more rules keep routes apart:

  • Make the routes siblings. Mount at /api/messages and /api/contacts, not /api and /api/contacts. A spec at /api puts its tag folders directly under /api, so if the Messages API had a Contacts tag, its pages would land inside the Contacts API's section without any warning.
  • Leave each spec's own routes to it. A hand-written page can join a spec's section: docs/api/messages/authentication.mdx appears at /api/messages/authentication, in that spec's sidebar. But docs/api/messages/index.mdx claims the overview's route, and the build stops with BLUME_DUPLICATE_ROUTE.

If two specs need different display options, such as other code sample languages or the Try it proxy on only one of them, list two openapi() adapters instead, each with its own route. Both default to /reference, and when two references resolve to the same route, Blume keeps the first and drops the other's pages.

Check every operation's route

An operation's page lives at its spec's route, then its first tag as a slug, then its operation ID split into words, so sendMessage becomes send-message. These are all the pages the two specs produce:

SpecOperationPage
MessagesOverview/api/messages
MessagesPOST /messages/api/messages/messages/send-message
MessagesGET /messages/{id}/api/messages/messages/get-message
MessagesGET /events/api/messages/events/list-events
MessagesGET /events/{id}/api/messages/events/get-event
ContactsOverview/api/contacts
ContactsPOST /contacts/api/contacts/contacts/create-contact
ContactsGET /contacts/{id}/api/contacts/contacts/get-contact
ContactsGET /events/api/contacts/events/list-events
ContactsGET /events/{id}/api/contacts/events/get-event

The two listEvents pages don't collide because each sits under its own spec's route. The word messages appears twice in some URLs because the tag shares the service's name; that's expected.

Compare that with merging both specs into one file first. Operation IDs are unique within a spec, so Blume gives the second listEvents a method suffix, /api/events/list-events-get, and warns BLUME_NAV_DUPLICATE_LABEL about the two List events entries. Separate specs keep both URLs predictable.

To confirm, run doctor. It scans the project the way a build does, without building anything, and prints every route collision. A clean run ends with "No problems found."

npx blume doctor

Then run npm run dev and open both overview pages. Each lists its operations by tag, linked to their pages.

Add navigation

A reference never adds a header tab on its own. How you add one decides whether readers see one spec at a time or all of them together.

One tab per API

For two or three APIs, give each spec its own tab:

navigation: {
  tabs: [
    { label: "Guides", path: "/" },
    { label: "Messages API", path: "/api/messages" },
    { label: "Contacts API", path: "/api/contacts" },
  ],
},

A tab scopes the sidebar to the pages under its path. On /api/contacts/events/list-events, the sidebar lists only the Contacts API, so the two List events entries never appear side by side. The Guides tab at / leaves both specs out of its sidebar.

One APIs tab for a portal

With more services, a tab per API crowds the header. Make one APIs tab a dropdown with an item per spec, and give /api a landing page:

navigation: {
  tabs: [
    { label: "Guides", path: "/" },
    {
      label: "APIs",
      path: "/api",
      items: [
        {
          label: "Messages API",
          path: "/api/messages",
          description: "Send email and SMS",
        },
        {
          label: "Contacts API",
          path: "/api/contacts",
          description: "Manage the people you message",
        },
      ],
    },
  ],
},
---
title: Acme APIs
description: Reference documentation for every Acme API.
---

Each Acme API has its own base URL and its own reference.

<CardGroup cols={2}>
  <Card title="Messages API" href="/api/messages">
    Send email and SMS, and track their delivery.
  </Card>
  <Card title="Contacts API" href="/api/contacts">
    Manage the people you message, and who has opted out.
  </Card>
</CardGroup>

The dropdown's path scopes the sidebar to everything under /api, so both specs share it. Out of the box, their groups are named after the last segment of each route (Messages, Contacts) and sorted alphabetically, and so are the tag groups inside them, which puts Events above Messages. A meta.ts in each spec's folder fixes all of that, even though the folder holds no pages of its own:

import { defineMeta } from "blume";

export default defineMeta({
  title: "Messages API",
  display: "page",
  order: 1,
  pages: ["messages", "events"],
});
import { defineMeta } from "blume";

export default defineMeta({
  title: "Contacts API",
  display: "page",
  order: 2,
  pages: ["contacts", "events"],
});

title names the group, order puts the Messages API first, and pages orders the tag groups by their slugs. display: "page" turns each spec into one sidebar row that opens into a panel of its own, so readers still see one API at a time.

Handle names that overlap

A lot already stays apart. URLs and sidebar groups are separate, as shown above. Each spec's schemas resolve within its own pages, so two schemas named Event never meet. And each operation's meta description ends with a sentence naming its spec's info.title, such as "Reference for the GET /events endpoint in the Acme Messages API."

The page title doesn't. Both pages are titled List events, so both browser tabs read "List events - Acme Docs", the search dialog lists two results with the same title, and blume audit reports BLUME_AUDIT_DUPLICATE_TITLE after a build.

If you own the specs, make the summaries specific. If another team owns them, rename the operations for the docs with an overlay per spec, and leave the spec files untouched:

overlay: 1.0.0
info:
  title: Messages API docs names
  version: 1.0.0
actions:
  - target: $.paths.*[?@.operationId == 'listEvents']
    update:
      summary: List message events
  - target: $.paths.*[?@.operationId == 'getEvent']
    update:
      summary: Get a message event
overlay: 1.0.0
info:
  title: Contacts API docs names
  version: 1.0.0
actions:
  - target: $.paths.*[?@.operationId == 'listEvents']
    update:
      summary: List contact events
  - target: $.paths.*[?@.operationId == 'getEvent']
    update:
      summary: Get a contact event

List each overlay on its own source:

sources: [
  {
    label: "Messages API",
    overlays: ["./overlays/messages.yaml"],
    route: "/api/messages",
    spec: "./specs/messages.yaml",
  },
  {
    label: "Contacts API",
    overlays: ["./overlays/contacts.yaml"],
    route: "/api/contacts",
    spec: "./specs/contacts.yaml",
  },
],

Each target selects the operation by its ID rather than its path, so the rename survives a path change. The URLs stay the same, since they come from the operation ID, not the summary. The same kind of overlay fixes two specs with the same info.title, which happens when both keep a framework's default: add an action that targets $.info and updates title.

Tune search across specs

Search indexes each operation by its summary, description, tag, and endpoint. A search for "events" finds all four event pages, and with the overlays in place, each result's title says which API it belongs to.

The filter pills above the results come from the sidebar group each page sits in, and for an operation that's its tag group. Both APIs' event pages share one Events pill. To split it, name one of the groups in a meta.ts inside the tag's folder:

import { defineMeta } from "blume";

export default defineMeta({ title: "Contact events" });

The sidebar group and the pill now read Contact events, and the URLs don't change.

Some specs belong in the portal without competing in search, such as a partner API or a legacy version. Set includeInSearch: false on that source to keep its pages out of site search, includeInLlms: false to keep them out of llms.txt, and noindex: true to add noindex metadata and drop them from the sitemap. The pages still build and stay in the sidebar.

Publish the combined site

Build the site, then check its links and titles:

npm run build
npx blume validate
npx blume audit --only duplicates

The build stops on errors, like a spec it can't read or a duplicate route. A route collision between two references is only a warning, so a build can succeed with one spec missing; that's why doctor belongs in the same routine. validate checks internal links, including links from your guides to operation pages in either spec, and audit --only duplicates lists pages that still share a title or description.

Each operation's Try it panel sends requests to its own spec's servers: Messages pages to api.acme.example, Contacts pages to contacts.acme.example. Every one of those APIs has to allow your docs origin under CORS, or you can route requests through a proxy. See Fix CORS errors in an API documentation playground.

If the site used to serve one spec at /reference, redirect the old URLs. Tag and operation slugs don't change when a spec moves, so one pattern covers every page:

redirects: [{ from: "/reference/:slug*", to: "/api/messages/:slug*" }],

Without the proxy, the output in dist/ is static files that any host can serve. The deployment docs cover each host.

Troubleshooting

One API's pages are missing

Two references resolved to the same route, and Blume kept the first. The warning is BLUME_OPENAPI_ROUTE_COLLISION: "Two API reference sources resolve to /reference; keeping the first." It usually means two openapi() adapters without a route, or two sources with the same label and no route. Give every source an explicit, distinct route.

The build fails with BLUME_DUPLICATE_ROUTE

A hand-written page sits at a route the reference generates, most often an index.mdx in a spec's folder, which collides with its overview. Move the page, or put the landing page one level up, like docs/api/index.mdx.

One broken spec fails the whole build

BLUME_OPENAPI_UNAVAILABLE names the spec Blume couldn't read or parse. In a build it's an error, so it stops the build even when the other specs are fine. In blume dev it's a warning, and only that spec's pages are skipped. Fix the path, the URL, or the file itself.

The sidebar says Messages, not Messages API

A spec's sidebar group takes its name from the route, not from the source's label. Add a meta.ts with a title in that spec's folder, as shown in Add navigation.

An overlay rename stopped working

An overlay action whose target matches nothing changes nothing, and there's no warning. If the spec's owners renamed an operation ID, update the target in your overlay.

A URL ends in -get or -post

Two operations in the same spec share an operation ID, so Blume added the method to the second one's URL. Give each operation a unique ID, or keep the services in separate specs as this guide does.

Next step

Check your routes

Run doctor in your project to catch two specs claiming the same route before a build drops one of them.

npx blume doctor
Read the doctor docs

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