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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| mkdocs-material | The reference sits inside a docs site you already build with MkDocs | A page generator you own, and a URL per tag that changes when an operation moves | You want a try-it console and schema views without building them |
| Redocly x-tagGroups | One page per API, built by Redocly CLI or Redoc | Grouping held in a vendor extension, and any tag left out of a group disappears | Your readers need a page per tag to share |
| Scalar | You want groups, ordering and nested tags, including the OpenAPI 3.2 parent field | Operation anchors built from method and path, under a tag prefix you cannot remove | Deep 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.
Related how-tos
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
-
The OpenAPI 3.2 Tag Object also gains
kind, a machine-readable string for what sort of tag it is. The specification suggestsnavfor navigation,badgefor visible badges andaudiencefor 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 -
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