# 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.

Source: https://voxgig.com/howto/organise-a-large-openapi-reference

- Audience: api-producer
- Level: intermediate
- Verified: 2026-10-15
- Published: 2026-10-15

## 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](https://squidfunk.github.io/mkdocs-material/setup/setting-up-navigation/) | 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](https://redocly.com/docs/realm/content/api-docs/openapi-extensions/x-tag-groups) | 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](https://scalar.com/products/api-references/openapi) | 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.

```yaml title="specs/openapi.yaml"
x-tagGroups:
  - name: Catalog
    tags:
      - Products
      - Categories
      - Prices
      - Inventory
  - name: Ordering
    tags:
      - Carts
      - Orders
      - Refunds
      - Shipments
```

The [Redocly documentation](https://redocly.com/docs/realm/content/api-docs/openapi-extensions/x-tag-groups)
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](https://spec.openapis.org/oas/v3.2.0.html#tag-object) to the
Tag Object, which puts the same hierarchy in the standard. Scalar
[reads it](https://scalar.com/products/api-references/openapi) 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.

```js title="nav.mjs"
// 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.

```bash
node nav.mjs specs/before.yaml
```

```text output
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.

```bash
node nav.mjs specs/openapi.yaml --previous specs/v1.yaml
```

```text output
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`](https://github.com/scalar/scalar/blob/main/documentation/configuration.md)
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.

```bash
node --test nav.test.mjs
```

```text output
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`](https://www.mkdocs.org/user-guide/configuration/). It
refuses the draft for the same reason Redoc and Scalar dropped operations.

```bash
node mkdocs-pages.mjs specs/before.yaml docs
```

```text output
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`](https://python-markdown.github.io/extensions/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.

```yaml title="mkdocs.yml"
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](https://redocly.com/blog/redoc-3-whats-new). 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 `$ref`s 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`](https://redocly.com/docs/cli/rules/oas/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

- [Detect a reference site drifting from its OpenAPI spec](/howto/check-a-reference-site-in-ci)

- [Split and bundle a large OpenAPI document](/howto/split-and-bundle-an-openapi-document)

- [Publish an OpenAPI document at a stable URL for agents](/howto/publish-openapi-at-a-stable-url)

## 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.

[^1]: The [OpenAPI 3.2 Tag Object](https://spec.openapis.org/oas/v3.2.0.html#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.

[^2]: Material for MkDocs 9.7.7 also printed a boxed warning on every build here, about
[MkDocs 2.0](https://squidfunk.github.io/mkdocs-material/blog/2026/02/18/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.