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.
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
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.
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.
Related
- Redocly & OpenAPI UI Configuration — the parent guide and the renderer this feeds.
- Defining JSON Schema Components — designing the components these fragments hold.
- Hiding internal endpoints in Swagger UI — filtering the bundle before publication.
- Detecting breaking changes with oasdiff — why both sides of a diff must be bundled first.