Splitting and Bundling OpenAPI with Redocly CLI

This guide is part of Redocly & OpenAPI UI Configuration within Developer Portal Frameworks & UI Setup. It covers the two complementary operations that make a large specification workable: splitting it into fragments people can review and own, and bundling those fragments back into the single document every downstream tool actually needs.

Two representations of one contract The split fragment tree is what people author and review, while the bundled single document is what renderers, mocks and generators consume. fragments — for humans paths/invoices.yaml paths/customers.yaml components/Invoice.yaml readable diffs · CODEOWNERS redocly bundle in CI, every build bundle — for machines renderers mocks and contract tests SDK generators · diffing one resolved document

Problem & Context

A specification that has grown past a few thousand lines creates two problems at once, and they pull in opposite directions.

For people, a single file is unreviewable. A pull request that adds one field to Invoice produces a diff nobody can locate in context; two teams editing different resources conflict on every merge because they are editing the same file; and nothing in the repository expresses who owns which part of the API.

For machines, multiple files are unusable. A browser fetching the root document cannot resolve a relative $ref to a sibling file that was never published — so the renderer lists every operation correctly and shows empty schema tables, with no error anywhere. Mock servers, contract testers, SDK generators, and diff tools all want one resolved document for the same reason.

The resolution is not to pick one. It is to author in fragments and generate the bundle, treating the bundle strictly as build output that nobody edits.

Step-by-Step Solution

1. Split the monolith into fragments

redocly split does the mechanical work, including rewriting every reference:

npm install --save-dev @redocly/cli@2
npx @redocly/cli@2 split openapi/api.yaml --outDir openapi/
Document: openapi/api.yaml is successfully split
  and all related files are saved to the directory: openapi/

openapi/openapi.yaml
openapi/paths/
openapi/components/

Inspect what it produced before committing anything:

find openapi -name '*.yaml' | head -8
openapi/openapi.yaml
openapi/paths/invoices.yaml
openapi/paths/invoices@{invoiceId}.yaml
openapi/paths/customers.yaml
openapi/components/schemas/Invoice.yaml
openapi/components/schemas/Customer.yaml
openapi/components/responses/NotFound.yaml
openapi/components/parameters/Cursor.yaml

2. Review and settle the directory layout

The generated layout is one file per path, which is finer-grained than most teams want. Reorganise once, early, to match ownership:

openapi/
├─ openapi.yaml                 # root: info, servers, tags, path references
├─ paths/
│  ├─ invoices.yaml             # every /invoices* operation — one owner
│  └─ customers.yaml            # every /customers* operation — one owner
└─ components/
   ├─ schemas/
   │  ├─ Invoice.yaml
   │  └─ Customer.yaml
   ├─ parameters/Cursor.yaml
   └─ responses/NotFound.yaml

Do this reorganisation as a single commit. Moving fragments later means rewriting every reference that pointed at them, and a half-migrated tree is worse than either layout.

3. Bundle for every downstream consumer

One command produces the document everything else reads:

npx @redocly/cli@2 bundle openapi/openapi.yaml -o dist/api.bundled.yaml

Confirm nothing external survived — this is the check that predicts whether renderers will show empty schemas:

grep -c '\$ref:[[:space:]]*[^#]' dist/api.bundled.yaml
0

A non-zero count means a fragment reference could not be resolved, and every schema behind it will render blank.

4. Assign ownership with CODEOWNERS

This is the payoff that a single file cannot give you:

# .github/CODEOWNERS
/openapi/paths/invoices.yaml            @acme/billing-team
/openapi/components/schemas/Invoice.yaml @acme/billing-team
/openapi/paths/customers.yaml           @acme/identity-team
/openapi/components/schemas/Customer.yaml @acme/identity-team
/openapi/openapi.yaml                   @acme/api-governance

The root document belongs to governance deliberately: it holds servers, security, and the tag structure, which are decisions that should not change without a cross-team review.

5. Gate CI on a clean bundle

Fragments that do not bundle are broken even though every individual file is valid YAML:

      - name: Bundle and validate
        run: |
          npx @redocly/cli@2 bundle openapi/openapi.yaml -o dist/api.bundled.yaml
          npx @redocly/cli@2 lint   dist/api.bundled.yaml
          # An unresolved external reference must fail the build.
          ! grep -q '\$ref:[[:space:]]*[^#]' dist/api.bundled.yaml
Which representation to use where Fragments are edited, reviewed and owned; the bundle is generated, published and consumed, and is never edited by hand. fragments — the source edited by hand · reviewed owned via CODEOWNERS committed to the repo never consumed directly by a tool bundle — build output generated every build published as an artifact gitignored never edited by hand

Complete Working Example

A make-style script that treats the bundle as a derived artifact and refuses to let the two representations diverge:

#!/usr/bin/env bash
# spec.sh — the only entry point for working with the specification.
set -euo pipefail

ROOT=openapi/openapi.yaml
BUNDLE=dist/api.bundled.yaml

cmd_bundle() {
  mkdir -p dist
  npx @redocly/cli@2 bundle "${ROOT}" -o "${BUNDLE}"
  npx @redocly/cli@2 lint "${BUNDLE}"

  # Unresolved external references are the failure that renders as empty schemas.
  if grep -q '\$ref:[[:space:]]*[^#]' "${BUNDLE}"; then
    echo 'ERROR: unresolved external $ref survived bundling:' >&2
    grep -n '\$ref:[[:space:]]*[^#]' "${BUNDLE}" | head >&2
    exit 1
  fi
  echo "OK   bundled $(find openapi -name '*.yaml' | wc -l) fragment(s) → ${BUNDLE}"
}

cmd_check() {
  # Every fragment must be reachable from the root, or it is dead weight
  # that nobody notices is stale.
  cmd_bundle >/dev/null
  local orphans=0
  while read -r f; do
    rel="${f#openapi/}"
    grep -rq "${rel}" openapi/ --include='*.yaml' --exclude="$(basename "$f")" \
      || { echo "ORPHAN ${f} — referenced by nothing"; orphans=1; }
  done < <(find openapi -name '*.yaml' ! -name openapi.yaml)
  [ "${orphans}" -eq 0 ] && echo 'OK   every fragment is referenced'
  return "${orphans}"
}

cmd_split() {
  # One-time migration from a monolith. Refuse to run over an existing tree.
  [ -d openapi/paths ] && { echo 'ERROR: already split' >&2; exit 1; }
  npx @redocly/cli@2 split openapi/api.yaml --outDir openapi/
  echo 'Split complete. Reorganise the tree to match ownership, then commit once.'
}

"cmd_${1:-bundle}"

Expected output:

OK   bundled 27 fragment(s) → dist/api.bundled.yaml
OK   every fragment is referenced

The orphan check is the one people do not think to write and end up wanting. Fragments get abandoned — a resource is removed from the root document but its schema file stays behind, and a year later somebody updates that dead file believing it is live. Reporting unreferenced fragments turns that into a build-time observation instead of a wasted afternoon.

Gotchas & Edge Cases

Splitting is a one-way door in practice. The command rewrites references across dozens of files, and the resulting commit is enormous. Do it on its own branch with no other changes, so the diff is reviewable as “mechanical split” and any later git blame still points at the real author of each line.

File names with path templates are awkward. A path like /invoices/{invoiceId} becomes a filename containing braces, which some tooling and some filesystems dislike. This is the main reason to consolidate per-path files into per-resource ones during step 2.

The bundle must be gitignored, and enforced. A committed bundle will eventually be edited directly by someone in a hurry, and then the fragments and the published contract disagree with no error. Add dist/ to .gitignore and add a CI check that fails if a bundled file appears in the tree.

Bundling and dereferencing are different operations. bundle keeps internal #/components/... references intact; a full dereference inlines everything and can explode a document with heavily reused schemas into something many times larger. Renderers want the bundle; only reach for full dereferencing if a specific tool demands it.

Fragment-level linting is not enough. Each file can be valid on its own while the assembled document is not — a duplicate operationId across two path fragments is the classic case, and nothing catches it until the pieces are combined. Always lint the bundle, not the fragments.

Split when the pain is real, not on principle. A specification under about a thousand lines with one owning team is genuinely easier to work with as a single file: you can read it top to bottom, search it without tooling, and there is no reference indirection to follow. The split earns its cost at the point where two things become true — more than one team edits the document regularly, and diffs have stopped being reviewable. Splitting earlier than that trades a readable file for a directory tree and a build step, in exchange for benefits nobody needs yet.

Expect the first week after a split to feel slower. Contributors who knew where everything was now have to follow references, and the muscle memory takes a little while to rebuild. Two things shorten that period considerably: a short README.md inside openapi/ that maps resources to files, and keeping the root document genuinely small so it works as a table of contents. If somebody has to grep to find where an operation is defined, the layout is wrong — the path in the root document should make it obvious.

When to split A single file is easier below roughly a thousand lines with one owning team; splitting pays off once several teams edit the document and diffs stop being reviewable. keep one file one owning team diffs still readable no build step, no indirection split it several teams editing merge conflicts routine ownership becomes expressible

FAQ

Why do renderers need the bundled file rather than the fragments?

A browser fetching the root document cannot resolve a relative $ref to a sibling file that was never published. The renderer lists the operations and leaves every schema table empty, with no error — which is why this failure is usually blamed on the renderer rather than on the missing bundle step.

Should I commit the bundled file?

No. It is build output derived from the fragments, and a committed copy will eventually be edited directly or merged stale. Generate it in CI and publish it as an artifact, exactly as you would any other build product.

How small should the fragments be?

One file per resource is the useful unit — small enough that a diff is readable and CODEOWNERS can route it to the right team, large enough that a single logical change does not touch six files at once.