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

Writing

Build a developer portal for multiple products

A two-product developer portal where each product has its own tab, sidebar, quickstart, and API reference, shared pages stay one click away, and search results name the product.

By 12 min read

To keep several products understandable in one developer portal, give each product its own URL prefix and put everything about it under that prefix: its guides at /messages and its API reference at /messages/api. Then add one header tab per product. A Blume tab scopes the sidebar to the routes under its path, so a reader inside one product sees only that product's guides and reference, and the breadcrumbs and previous and next links stay inside it too.

By the end, you have a portal for two fictional Acme products: Messages, which sends email and SMS, and Verify, which sends one-time codes. Each has an overview, a quickstart, and an API reference generated from its own OpenAPI spec. Pages both products share, like authentication, sit outside either product and stay one click away from every page, and search results say which product they belong to.

If your products are small, Blume's default may be enough: every folder becomes a sidebar group with no configuration. Tabs pay off once one sidebar is long enough that readers lose track of which product they're in. For the details of mounting several specs, see Combine multiple OpenAPI specifications in one docs site. And if some products need their own sign-in or their own release versions, they belong in separate sites, as explained below.

Map products to routes

Decide the URL map before you write pages, because tabs match routes by prefix. A tab owns every route under its path, on a path boundary, so /verify never claims /verify-legacy. When two tabs match, the longer path wins. Here's the map for the two products:

docs.acme.example
│
├── Overview tab   path "/"   (pages no product owns)
│     /                             docs/index.mdx
│     /platform/authentication      docs/platform/authentication.mdx
│
├── Messages tab   path "/messages"
│     /messages                     docs/messages/index.mdx
│     /messages/quickstart          docs/messages/quickstart.mdx
│     /messages/api                 generated from openapi/messages.yaml
│     /messages/api/messages/...    one page per operation
│
└── Verify tab   path "/verify"
      /verify                       docs/verify/index.mdx
      /verify/quickstart            docs/verify/quickstart.mdx
      /verify/api                   generated from openapi/verify.yaml
      /verify/api/verifications/... one page per operation

On every page: a featured link to Authentication, and one search index

Three rules make this map work:

  • Product first, content type second. Mount the reference at /messages/api, not /api/messages. A reference outside the product's prefix falls under the Overview tab, next to every other product's reference, and readers lose the product's sidebar the moment they open an endpoint.
  • Shared pages outside every product. A route belongs to one tab at most, so authentication, errors, and other pages both products need go under /platform, which no product tab claims.
  • Stable slugs. Every URL in a product starts with its prefix, so renaming the prefix later means a redirect for every page.

Write each product's pages

Start from a Blume project (npx blume init, docs template, content in docs/). The home page is the portal's front door, with a card per product:

---
title: Acme developer docs
description: Guides and API references for Acme Messages and Acme Verify.
---

Pick a product to start. Both use the same API key.

<CardGroup cols={2}>
  <Card title="Messages" href="/messages" icon="mail">
    Send transactional email and SMS.
  </Card>
  <Card title="Verify" href="/verify" icon="shield-check">
    Confirm a phone number or email address with a one-time code.
  </Card>
</CardGroup>

Each product gets an overview and a quickstart. Give every page a title that names its product, because the title is what browser tabs and search results show. sidebar.label keeps the sidebar row short, since the tab already tells the reader which product they're in:

---
title: Acme Messages
description: Send transactional email and SMS from your app with Acme Messages.
sidebar:
  label: Overview
---

Acme Messages sends transactional email and SMS through one API.

- [Send your first message](/messages/quickstart)
- [Messages API reference](/messages/api)
---
title: Send your first message
description: Send a transactional email with the Acme Messages API.
sidebar:
  label: Quickstart
search:
  keywords: [quickstart, get started]
---

<include product="Messages">/_snippets/api-key.mdx</include>

## 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 response holds the message ID. Pass it to
[Get a message](/messages/api/messages/get-message) to check whether it arrived.

Both quickstarts open with the same step, getting an API key. Write it once as a partial and pass the product name in as a prop, which the partial reads as {{product}}. Files and folders whose names start with an underscore stay out of routing, navigation, and search. For more on partials, see Reuse Markdown snippets and variables.

## Get an API key

Create an API key in the Acme dashboard and export it as `ACME_API_KEY`. The
same key works for {{product}} and every other Acme product. See
[Authentication](/platform/authentication) for how requests send it.

Verify's pages follow the same shape:

---
title: Acme Verify
description: Confirm a phone number or email address with a one-time code.
sidebar:
  label: Overview
---

Acme Verify sends a one-time code and checks the code your user types back.

- [Verify a phone number](/verify/quickstart)
- [Verify API reference](/verify/api)
---
title: Verify a phone number
description: Send a one-time code by SMS and check it with the Acme Verify API.
sidebar:
  label: Quickstart
search:
  keywords: [quickstart, get started]
---

<include product="Verify">/_snippets/api-key.mdx</include>

## Send a code

```bash
curl https://api.acme.example/v1/verifications \
  -H "Authorization: Bearer $ACME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"channel":"sms","to":"+15555550100"}'
```

Then pass the code your user enters to
[Check a code](/verify/api/verifications/check-verification).

Last, the shared page, in a folder no product owns:

---
title: Authentication
description: Send your Acme API key as a bearer token to any Acme API.
---

One API key works for every Acme product. Send it in the `Authorization`
header as a bearer token:

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

Mount each product's API reference

Save each product's spec in an openapi/ folder beside blume.config.ts. Both APIs share a server and an API key, which is why one authentication page serves both.

openapi: 3.1.0
info:
  title: Acme Messages API
  version: 1.0.0
  description: Send transactional email and SMS.
servers:
  - url: https://api.acme.example/v1
security:
  - bearerAuth: []
tags:
  - name: Messages
    description: Send a message and check whether it arrived.
  - name: Templates
    description: Reusable message bodies with variables.
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.
  /templates:
    get:
      operationId: listTemplates
      tags: [Templates]
      summary: List templates
      description: Returns every template in the workspace.
      responses:
        "200":
          description: The templates.
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
openapi: 3.1.0
info:
  title: Acme Verify API
  version: 1.0.0
  description: Send one-time codes and check them.
servers:
  - url: https://api.acme.example/v1
security:
  - bearerAuth: []
tags:
  - name: Verifications
    description: Send a code, then check the one the user entered.
paths:
  /verifications:
    post:
      operationId: startVerification
      tags: [Verifications]
      summary: Start a verification
      description: Sends a one-time code by SMS or email and returns the verification's ID.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [channel, to]
              properties:
                channel:
                  type: string
                  enum: [sms, email]
                to:
                  type: string
      responses:
        "201":
          description: The code is on its way.
  /verifications/{id}/check:
    post:
      operationId: checkVerification
      tags: [Verifications]
      summary: Check a code
      description: Checks the code the user entered and says whether it matched.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code:
                  type: string
      responses:
        "200":
          description: Whether the code matched.
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer

Then list both as sources of one openapi() adapter, each routed under its product:

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

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

Each source gets an overview page at its route, titled with the spec's info.title, and a page per operation built from its route, its tag, and its operation ID: /messages/api/messages/send-message, /verify/api/verifications/check-verification, and so on. Those are the URLs the quickstarts link to.

A reference's sidebar group is named after the last segment of its route, so both groups would read API, and so would their filter in search. Name them with a meta.ts in each route's folder. The folders hold no pages, only the meta:

import { defineMeta } from "blume";

export default defineMeta({ title: "Messages API" });
import { defineMeta } from "blume";

export default defineMeta({ title: "Verify API" });

Don't add an index.mdx to those folders, because the reference's overview already owns that route. The two sources share display options like code sample languages. If your products need different ones, or their specs share operation names, the multiple-specs guide covers that setup.

Scope the sidebar with tabs

Add a tab per product, an Overview tab for the shared pages, and a featured link to the page every product needs:

navigation: {
  tabs: [
    { label: "Overview", path: "/" },
    { label: "Messages", path: "/messages", icon: "mail" },
    { label: "Verify", path: "/verify", icon: "shield-check" },
  ],
  featured: [
    { label: "Authentication", href: "/platform/authentication", icon: "key" },
  ],
},

Here's what a reader sees from each part of the portal:

Reader is onCurrent tabSidebar lists
/, /platform/authenticationOverviewThe home page and the Platform group
/messages/quickstartMessagesOverview, Quickstart, and the Messages API group
/verify/api/verifications/check-verificationVerifyOverview, Quickstart, and the Verify API group

Breadcrumbs and previous and next links come from the scoped sidebar, so the Messages quickstart's next link goes to the Messages API overview, never to a Verify page. On the Overview tab, Blume hides every folder that has its own tab, so the root sidebar lists only the pages no product owns.

That's also why shared pages need help: inside a product, the sidebar doesn't list /platform. Featured links sit above the sidebar on every route, whatever tab is current, so Authentication stays one click away from either product. The API key partial links to it as well.

Make search results name the product

Search covers the whole portal from one index. Results aren't scoped to the product the reader is in, and each row shows a page's title and an excerpt, not its product. Four habits keep results readable:

  • Product-specific titles. Two pages titled Quickstart look identical in results. Send your first message and Verify a phone number don't.
  • Keywords for generic terms. Readers still type "quickstart", so both quickstarts set search.keywords. The query finds both, and their titles tell them apart.
  • Distinct group names. When results span several sidebar groups, the dialog shows a filter pill per group. Each page files under its nearest group: a quickstart under its product, an operation under its tag, and a reference's overview under the reference's own group. That's why the reference folders got titles of their own; without them, both overviews would file under API.
  • Pinned starting points. Before a reader types, the dialog lists popular pages, by default the first six in the full sidebar. Pin each product's quickstart instead.
search: {
  popular: [
    { href: "/messages/quickstart", icon: "mail", label: "Send your first message" },
    { href: "/verify/quickstart", icon: "shield-check", label: "Verify a phone number" },
    { href: "/platform/authentication", icon: "key", label: "Authentication" },
  ],
},

If two products' specs share operation summaries, like List events in both, rename them for the docs with an overlay, as the multiple-specs guide shows. After a build, npx blume audit --only duplicates lists pages that still share a title.

Tabs or a product selector

A selector is a dropdown beside the logo. One with kind: "product" shows the product the reader is in and lists every product, each with an optional description. But a selector only links: the sidebar, breadcrumbs, and previous and next links follow tabs, never selectors. kind is a hint, and every kind renders the same dropdown.

With two or three products, tabs do both jobs, so leave the selector out. Next to product tabs, it would list the same products twice. Switch when the products no longer fit in the header's tab row: remove the product tabs, add the selector, and turn each product folder into a drill-in panel.

navigation: {
  selectors: [
    {
      kind: "product",
      label: "Product",
      items: [
        { label: "All products", path: "/", icon: "layout-grid" },
        {
          label: "Messages",
          path: "/messages",
          icon: "mail",
          description: "Transactional email and SMS",
        },
        {
          label: "Verify",
          path: "/verify",
          icon: "shield-check",
          description: "One-time codes",
        },
      ],
    },
  ],
  featured: [
    { label: "Authentication", href: "/platform/authentication", icon: "key" },
  ],
},
import { defineMeta } from "blume";

export default defineMeta({ display: "page" });

Add the same meta.ts to docs/verify/. Then:

  • Each product is one sidebar row. Landing on a page inside a product opens its panel, with a back arrow to the rest of the portal.
  • The selector shows the item whose path is the longest match for the current route, and its first item when none matches. All products at / matches every route, so shared pages show it instead of claiming to be part of Messages.
  • Previous and next links run across the whole portal, from the last Messages page into Platform and on into Verify, because nothing scopes them anymore.

Keep products apart from versions and access

A product switcher, a version switcher, and a login wall can all look like a choice in the header. Blume treats them differently.

Versions are site-wide. The versions config snapshots the whole content tree, not one product, and adds its own version switcher. API references generated from specs stay current in every snapshot, and tabs don't scope an archived tree's sidebar. If only Messages ships a breaking change, keep its old API inside the product as a second source at a route like /messages/api-v1, with includeInSearch: false so it doesn't compete with the current one. For a release of the whole portal, see Version your docs for a breaking release. And don't give a product selector kind: "version": on a versioned site, a selector of that kind replaces the automatic version switcher.

Access is per site. Blume has no sign-in. Leaving a product out of the tabs or the selector doesn't hide it: its pages are still in the build, the search index, the sitemap, and llms.txt. Host protection covers a whole site, so a partner-only or internal product belongs in a separate site behind it. See Private docs and Protect an internal documentation site with Cloudflare Access.

Ownership can be per repository. If each product team keeps its docs beside its code, the portal can still own the structure by mounting each repository under its own prefix and tab, as Build one documentation portal from multiple GitHub repositories shows.

Test every entry point

Readers arrive on the home page, on a product overview, on a quickstart from a search engine, and deep in an endpoint from someone's link. Check each one. First, scan the project for duplicate routes and unknown icons:

npx blume doctor

Then run npx blume dev and open each entry point:

OpenCurrent tabNext link goes to
/OverviewAuthentication
/messages/quickstartMessagesThe Messages API overview
/messages/api/messages/send-messageMessagesList templates
/verify/api/verifications/check-verificationVerifyStart a verification

On each page, check that the sidebar shows only that tab's pages and that the Authentication link sits above it. Then open search from a Messages page and from a Verify page. An empty query should list the three pinned pages, and "quickstart" should find both quickstarts under their own titles. Finally, build and check links and titles:

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

validate checks every internal link, including the quickstarts' links into the generated references, and the audit flags pages that share a title or description.

Troubleshooting

A product's API reference shows under Overview

Its route isn't under the product's prefix, such as /api/messages beside a /messages tab. The longest matching tab is then the root one, so the reference joins the Overview sidebar and the product's tab loses it. Move the route under the product, and redirect the old operation URLs if they were ever published.

The build fails with BLUME_DUPLICATE_ROUTE

A page sits at a route the reference generates, most often an index.mdx in docs/messages/api/, which collides with the Messages API overview. Delete it or move it up a level. Keep only meta.ts in a reference's folder.

Both reference groups are named API

The meta.ts is missing or in the wrong folder. It has to sit in the folder that matches the reference's route, so /messages/api reads docs/messages/api/meta.ts.

A tab opens a product's first page instead of its overview

The product's folder has no index.mdx, so the tab falls back to the first page in its section. Add the overview page, or set the tab's href to the page it should open.

The dev server warns BLUME_NAV_MISSING_PAGE

A tab, selector item, or featured link points at a route with no page, often a typo like /verfy. blume dev and blume build print it as a warning, but blume doctor doesn't check navigation targets, so a clean doctor run can still hide it. Fix the path in blume.config.ts.

Next step

Check your portal's routes

Run doctor to catch duplicate routes and unknown icons, then tune each product's reference with the multiple-specs guide.

npx blume doctor
Read the multiple-specs 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