How-to › Describe your API

How to split and bundle a large OpenAPI document#

Break a multi-thousand-line description into per-resource files, then produce the single bundled document most generators expect, without losing component names.

Audience
Platform team
Level
intermediate
Topic
Write an OpenAPI description
Verified

Two people add an endpoint on the same afternoon and the merge conflict runs to 300 lines of YAML. The description is one file, so every change touches it, and review comments land on line numbers rather than on resources. Nobody can tell from the diff whether the billing team changed the billing paths or something else.

What you get

You will end up with one file per path and one per schema, plus a bundling step that produces the single document generators and validators expect. The bundle keeps component names, so a discriminator still resolves. This is for you if more than one team edits the description.

Short answer

Keep one file per path and one per schema, joined by relative $ref values, and bundle them into a single document in CI. Bundle by reference rather than by value: hoist each external schema to a named entry under components.schemas and rewrite the pointer. Dereferencing instead inlines the schema, drops its name, and breaks any discriminator mapping that named it.

You will need

An OpenAPI 3 description large enough that two people edit it at once, and Node 22 or later. The $ref rules come from JSON Reference as OpenAPI 3.1 adopts them, and relative paths resolve against the file that contains them.1

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
One file with strict orderingA description under a thousand lines, edited by one teamMerge conflicts that grow with the team, and reviews that argue about line numbersTwo teams own different resources in the same description
openapi-format splitYou also want sorting and filtering applied on the way throughA tool whose main job is formatting, with splitting as a second concernYou only need the split and the join, with nothing rewritten
Redocly CLI split and bundleMost teams, because one tool does both directions and the lintingA configuration file, and a layout convention you inherit rather than chooseYou need the bundle shaped differently from what the tool produces
swagger-cli bundleAn existing pipeline already calls it and nobody wants to change thatA deprecated package, so a fix you need may never arriveAnything new, where a maintained tool is available instead

The real decision is not which tool, it is whether you commit the bundled file. Committing it means consumers can read the repository without running anything, at the cost of a generated artifact in every diff. Building it in CI keeps the diff clean, and means a consumer who clones the repository gets files that most generators cannot read. Pick one and say so in the README, because a half-committed bundle that drifts from its sources is worse than either.

Lay out the files so the refs stay short

Paths in one directory, schemas in another, each referenced by a path relative to the file that names it.

{
  "openapi": "3.1.0",
  "info": { "title": "Meters", "version": "1.4.0" },
  "paths": {
    "/meters": { "$ref": "./paths/meters.json" },
    "/meters/{id}": { "$ref": "./paths/meter.json" }
  },
  "components": {
    "schemas": {
      "Meter": { "$ref": "./components/schemas/meter.json" },
      "Problem": { "$ref": "./components/schemas/problem.json" }
    }
  }
}

A path file references a schema with ../components/schemas/meter.json, because the reference resolves against the path file and not against the root. That is the rule people get wrong on the first day, and the symptom is a bundle that succeeds while pointing at a file that does not exist.

Bundle by reference, not by value

Two operations share the name “bundle” and they produce different documents.2

if (typeof node.$ref === 'string' && !node.$ref.startsWith('#')) {
  const { file, body } = load(base, node.$ref)
  const name = nameFor(file)
  if (!schemas[name]) schemas[name] = walk(body, file)
  return { $ref: `#/components/schemas/${name}` }
}

Hoisting keeps a name for every schema, and every reference becomes a local pointer. Dereferencing copies the schema into every place it was used. The result validates and generates badly.3 A client generator sees four anonymous objects where you had one Meter, and it names each of them after the operation that returned it.

Path items are the exception. They get inlined, because there is nowhere to name one that generators read reliably. Components has a pathItems slot in OpenAPI 3.1, and tool support for it is thin enough that a bundle relying on it will fail somewhere in your toolchain.

The rewrite is mechanical, which is the point. A bundler that only moves references can be checked by walking the output: no pointer may leave the document, and every local pointer must resolve. Two assertions, both cheap, both in the tests below.

Check it worked

Bundle the tree and assert the two properties that matter: nothing external is left, and every local pointer resolves.

node demo.mjs
paths inlined:     /meters, /meters/{id}
schemas named:     Meter, Problem
external refs:     0
dangling refs:     0
COLLISION Meter: kept components/schemas/meter.json, dropped components/schemas/legacy/meter.json

The last line is the failure this whole exercise exists to catch. Two files named meter.json in different directories both want the component name Meter, so one wins and the other disappears into it. The bundle still validates. The legacy media type now describes the wrong shape without the document saying so.

node --test bundle.test.mjs
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 114.772499

When it goes wrong

A validator reports a missing schema that is plainly there. The reference resolved against the root rather than against the file holding it. Open the referencing file and count the directory steps from there.

A generated client has types named after operations. The bundle was dereferenced. Switch to the hoisting form, and check that components.schemas still lists the names your team uses.

The bundled file changes on every build with no source change. A tool is reordering keys, or emitting the properties in map order. Sort deterministically before writing, or the artifact is noise in every pull request.

A schema appears twice under two names. Two files describe the same shape, and the split made that easy to miss. Merge them by content when you bundle, or keep both and give each a name that says which it is.

When not to do this

Do not split a description that one person maintains. The layout costs a build step and a mental model, and it buys conflict-free editing that a single editor never needed. Wait for the second team, or for the first merge conflict that takes an afternoon to resolve.

Do not let consumers read the split tree. Many generators and some validators resolve only local references, and a consumer who points a tool at the root file gets an empty client and a confusing error. Publish the bundle, and treat the tree as source. Say which one is canonical in the README, and give the bundle a filename that makes its role plain.

Do not use file names as component names by accident. The bundler derives one from the other, so two files with the same basename collide silently. Name the component in the file, or fail the build when two files claim one name. The build failure is cheap and the silent overwrite is not.

Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

Footnotes

  1. JSON Reference is an expired Internet-Draft. The Datatracker files draft-pbryan-zyp-json-ref-03 as an individual submission, last revised 2012-09-16, with no RFC stream and no intended status. OpenAPI 3.0.3 still says its Reference Object is defined by JSON Reference. Version 3.1.0 says instead that the Schema Object is a superset of JSON Schema 2020-12, whose core specification defines $ref in section 8.2.3.1 without the draft’s help. The pointer outlived the document that defined it. ↩︎ Back to text

  2. The two tools in the table agree on which operation is which. Redocly CLI bundles by reference and offers --dereferenced for a document with no $ref at all, which its page says can be useful for a tool that does not understand the syntax. swagger-cli did the same under --dereference, one letter shorter, and its README now opens by recommending Redocly instead, with a link to a migration guide. The default survived the tool. ↩︎ Back to text

  3. The Discriminator Object says that when a discriminator is in use, inline schemas will not be considered. A schema copied into place is an inline schema. A dereferenced bundle therefore does not merely lose the name. It loses the one mechanism whose job was to look the name up. ↩︎ 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.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.