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 Hayden Bleasel11 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: beareropenapi: 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: bearerBoth 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/messagesand/api/contacts, not/apiand/api/contacts. A spec at/apiputs 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.mdxappears at/api/messages/authentication, in that spec's sidebar. Butdocs/api/messages/index.mdxclaims the overview's route, and the build stops withBLUME_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:
| Spec | Operation | Page |
|---|---|---|
| Messages | Overview | /api/messages |
| Messages | POST /messages | /api/messages/messages/send-message |
| Messages | GET /messages/{id} | /api/messages/messages/get-message |
| Messages | GET /events | /api/messages/events/list-events |
| Messages | GET /events/{id} | /api/messages/events/get-event |
| Contacts | Overview | /api/contacts |
| Contacts | POST /contacts | /api/contacts/contacts/create-contact |
| Contacts | GET /contacts/{id} | /api/contacts/contacts/get-contact |
| Contacts | GET /events | /api/contacts/events/list-events |
| Contacts | GET /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 doctorThen 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 eventoverlay: 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 eventList 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 duplicatesThe 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 doctorA step here not working for you? Report a broken step.