How-to › Document and support developers

How to organize a large OpenAPI reference by tags and tag groups#

Group a 200-operation reference into tags and tag groups that every renderer shows, check each operation is two clicks away, and keep old deep links working.

Audience
API producer
Level
intermediate
Topic
Publish reference docs
Verified

You add x-tagGroups to tidy the sidebar of a 200-operation reference. The build passes, but ten operations under a tag nobody added to a group have vanished from the page, along with two that were never tagged. Support’s saved links to the refund endpoint now open the top of the page, because the regrouping moved it to another tag.

What you get

You will end up with a grouped sidebar in Redoc and Scalar, and a CI check that fails when an operation is out of reach or an old anchor stops resolving. This is for you if your reference has outgrown one scroll.

Short answer

Tag every operation, group the tags with x-tagGroups, and check the sidebar the renderer built rather than the spec. Redoc and Scalar both drop a tag that no group names, and Redoc drops untagged operations too. Moving an operation to another tag breaks its deep link in both, so publish Redoc’s #operation/ anchor, rename tags with x-displayName, and keep operations where they were.

You will need

Node 22 or later, and an OpenAPI 3.1 document with an operationId on every operation. Verified against Node 22.22.0. Publishing a Scalar reference in the first place is covered by the page on publishing an OpenAPI reference with Scalar. The sample here is a generated storefront API: 200 operations under 20 tags in five groups.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
mkdocs-materialThe reference sits inside a docs site you already build with MkDocsA page generator you own, and a URL per tag that changes when an operation movesYou want a try-it console and schema views without building them
Redocly x-tagGroupsOne page per API, built by Redocly CLI or RedocGrouping held in a vendor extension, and any tag left out of a group disappearsYour readers need a page per tag to share
ScalarYou want groups, ordering and nested tags, including the OpenAPI 3.2 parent fieldOperation anchors built from method and path, under a tag prefix you cannot removeDeep links must survive an operation changing tag

Redoc and Scalar read the grouping from the spec, so it moves with the document and costs one list to keep current. That list is a Redocly extension, which Scalar also honors, so moving between the two keeps it, and a third tool may ignore it. mkdocs-material keeps the structure in your own generator, at the price of a script to maintain and per-tag URLs.

Group the tags in the spec

Every tag goes in exactly one group. The list sits at the top level of the document.

x-tagGroups:
  - name: Catalog
    tags:
      - Products
      - Categories
      - Prices
      - Inventory
  - name: Ordering
    tags:
      - Carts
      - Orders
      - Refunds
      - Shipments

The Redocly documentation is blunt about the rule that matters. “Any tag that is not in a group will not be included in the output.” OpenAPI 3.2 adds a parent field to the Tag Object, which puts the same hierarchy in the standard. Scalar reads it and falls back to x-tagGroups when no valid parent exists.1

Read the sidebar the renderer built

A spec-level check says what should appear. The rendered page says what does. nav.mjs builds the page with redocly build-docs and with Scalar’s server-side renderer, then reads each sidebar item and its aria-expanded state from the HTML.

// Redoc writes every menu item, opened or not, with aria-expanded on the item itself. Without
// that markup every count below would be wrong, so its absence stops the check.
export function redocNav(html) {
  const items = parse(html).querySelectorAll('li[data-item-id]')
  if (!items.some((li) => li.hasAttribute('aria-expanded'))) {
    throw new Error('the Redoc page has no menu items with data-item-id and aria-expanded: its markup changed')
  }
  const nav = new Map()
  for (const li of items) {
    const id = li.getAttribute('data-item-id')
    const m = /(?:^|\/)operation\/([^/]+)$/.exec(id)
    if (!m) continue
    const closed = ancestors(li).filter((a) => a.getAttribute('aria-expanded') === 'false').length
    nav.set(m[1], { anchor: `#${id}`, clicks: closed + 1 })
  }
  return nav
}

A click is one closed container over the operation, plus the operation itself. Scalar renders only what is open, so the script renders it twice, once with defaultOpenAllTags, and compares. Run it on the draft that left out the Reports tag and two Files operations.

node nav.mjs specs/before.yaml
specs/before.yaml: 200 operations, 20 tags, 5 groups
renderer  linked  1 click  2 clicks  3+ clicks  missing
redoc     188     0        188       0          12
scalar    190     2        188       0          10
missing from redoc: 2 untagged (listFiles, getFile)
missing from redoc: 10 under Reports, which no x-tagGroups entry names (listReports, createReport, getReport, ...)
missing from scalar: 10 under Reports, which no x-tagGroups entry names (listReports, createReport, getReport, ...)

Redoc and Scalar disagree about untagged operations. Redoc drops them once groups exist, and Scalar lists them at the top of the sidebar, outside every group. The documentation for neither tool says what happens to them, so the HTML is the only evidence. Both drop the tag no group names. Grouping added no clicks in either: Redoc renders groups as open labels, and Scalar opened all five.

Check it worked

Run the check on the fixed draft, and compare it with the release your readers have bookmarked.

node nav.mjs specs/openapi.yaml --previous specs/v1.yaml
specs/openapi.yaml: 200 operations, 20 tags, 5 groups
renderer  linked  1 click  2 clicks  3+ clicks  missing
redoc     200     0        200       0          0
scalar    200     0        200       0          0
redoc anchor from specs/v1.yaml no longer resolves: #tag/Orders/operation/refundOrder; #operation/refundOrder still resolves
scalar anchor from specs/v1.yaml no longer resolves: #tag/orders/POST/orders/{id}/refund

Every operation is two clicks away in both, and 199 of 200 old anchors still resolve. The one that does not is refundOrder, which moved from Orders to Refunds. Redoc’s page carries a second id per operation, operation/<operationId>, with no tag in it. Scalar’s generateOperationSlug can change the last segment, but a tag/<tag-name>/ prefix is always added.

The check exits 1 on either problem, so it can gate a pull request. Its tests cover both Redoc and Scalar on a small spec, markup it cannot read, a split spec, and the MkDocs generator.

node --test nav.test.mjs
1..9
# tests 9
# suites 0
# pass 9
# fail 0

Build per-tag pages for mkdocs-material

A hand-built sidebar gives you a page per tag, and a URL you can share for a whole area of the API. mkdocs-pages.mjs writes one Markdown page per tag and the nav as mkdocs.nav.yml, which mkdocs.yml pulls in with INHERIT. It refuses the draft for the same reason Redoc and Scalar dropped operations.

node mkdocs-pages.mjs specs/before.yaml docs
refused: 12 operation(s) would have no page: listFiles, getFile, listReports, createReport, getReport, deleteReport, updateReport, archiveReport, restoreReport, getReportHistory, searchReports, exportReports

Each heading carries its operationId as an explicit id through attr_list, so rewording a summary never moves an anchor. Groups sit at the top of the nav, where navigation.sections shows them as labels.

INHERIT: mkdocs.nav.yml
site_name: Storefront API reference
theme:
  name: material
  features:
    - navigation.sections
    - toc.follow
markdown_extensions:
  - attr_list
  - toc:
      permalink: true

Built locally with MkDocs 1.6.1 and mkdocs-material 9.7.7, outside the captured runs, the Refunds page carried id="refundOrder" and the sidebar five sections. The mkdocs-material documentation says those sections render as groups only on screens wider than 1220px, so a phone reader opens one more level.2

When it goes wrong

A tag disappears from the sidebar. It is in tags but in no group. Add it to one, and fail CI when nav.mjs reports anything missing.

Every deep link breaks at once. You renamed a tag, and Redoc and Scalar both build anchors from the tag name. Change the label with x-displayName instead: run here, Redoc kept #tag/Orders and Scalar kept #tag/orders while both showed the new label.

The check reads markup that may change. nav.mjs looks for the data-item-id and aria-expanded attributes Redoc 2 writes, and Redocly CLI 2.54.3 prints a notice that build-docs is moving to Redoc 3. If an upgrade drops that markup, the check stops with exit 2 and names it, instead of reading an empty sidebar as a clean one. Pin the CLI, and update the reader when you move.

The check refuses a split spec. It reads one file, so path items that are $refs to other files hold no operations it can see, and zero operations would mean zero missing. Run redocly bundle first and check the bundle. A spec with no operations at all is refused for the same reason.

Untagged operations vanish from Redoc and sit outside every group in Scalar. A lint rule catches them before either renders. Run here, the Spectral rule operation-tags warned on an operation with no tags, so raise it to error to fail CI. Redocly’s operation-tag-defined catches a tag that is used but not declared.

When not to do this

Do not add groups to a reference with a handful of tags. A layer of labels over ten operations only gives the reader more to scan.

Do not regroup tags and move operations in the same release. Adding the groups here kept 199 of 200 anchors in Redoc and in Scalar, and the one it broke was the operation that changed tag. Move operations when you are ready to publish redirects.

Do not hand-build a sidebar to escape a vendor extension. x-tagGroups is one list in the spec, and the MkDocs route trades it for a generator you maintain alone. Choose it when you need a page per tag, not to avoid the extension.

Do not trust a green build. Neither redocly build-docs nor the Scalar renderer printed a word about the operations it left out.

Last verified

Verified 2026-10-15 against Node 22.22.0, @redocly/cli 2.54.3, @scalar/server-side-rendering 0.1.55 with @scalar/api-reference 1.72.3, node-html-parser 9.0.4 and yaml 2.9.1. Every output block is what the preceding command printed in the page’s code directory after npm ci. Clicks are counted in the HTML each renderer produces on the server, before any script runs in a browser.

Footnotes

  1. The OpenAPI 3.2 Tag Object also gains kind, a machine-readable string for what sort of tag it is. The specification suggests nav for navigation, badge for visible badges and audience for the groups an API serves, and points to a registry of common values. A tag that was always a navigation device can now say so. ↩︎ Back to text

  2. Material for MkDocs 9.7.7 also printed a boxed warning on every build here, about MkDocs 2.0. It says the next major version of the framework removes the plugin system, rewrites theming and offers no migration path. A theme now ships a notice about the framework beneath it, and the hand-built sidebar inherits that question along with the theme. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.