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

Source: https://voxgig.com/howto/split-and-bundle-an-openapi-document

- Audience: platform-team
- Level: intermediate
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## 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](https://json-schema.org/draft/2020-12/json-schema-core) as
[OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0.html) adopts them, and relative paths resolve
against the file that contains them.[^1]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [One file with strict ordering](https://spec.openapis.org/oas/v3.1.0.html#document-structure) | A description under a thousand lines, edited by one team | Merge conflicts that grow with the team, and reviews that argue about line numbers | Two teams own different resources in the same description |
| [openapi-format split](https://github.com/thim81/openapi-format) | You also want sorting and filtering applied on the way through | A tool whose main job is formatting, with splitting as a second concern | You only need the split and the join, with nothing rewritten |
| [Redocly CLI split and bundle](https://redocly.com/docs/cli/commands/bundle) | Most teams, because one tool does both directions and the linting | A configuration file, and a layout convention you inherit rather than choose | You need the bundle shaped differently from what the tool produces |
| [swagger-cli bundle](https://github.com/APIDevTools/swagger-cli) | An existing pipeline already calls it and nobody wants to change that | A deprecated package, so a fix you need may never arrive | Anything 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.

```json title="api/openapi.json"
{
  "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]

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

```bash
node demo.mjs
```

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

```bash
node --test bundle.test.mjs
```

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

## Related how-tos

- [Validate responses against the OpenAPI document at runtime](/howto/validate-responses-against-the-openapi-document)

- [Choose a pagination style for a list endpoint](/howto/choose-a-pagination-style-for-a-list-endpoint)

## Last verified

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

[^1]: JSON Reference is an expired Internet-Draft. The Datatracker files
[draft-pbryan-zyp-json-ref-03](https://datatracker.ietf.org/doc/draft-pbryan-zyp-json-ref/) as
an individual submission, last revised 2012-09-16, with no RFC stream and no intended status.
[OpenAPI 3.0.3](https://spec.openapis.org/oas/v3.0.3.html) 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](https://json-schema.org/draft/2020-12/json-schema-core)
defines `$ref` in section 8.2.3.1 without the draft's help. The pointer outlived the document
that defined it.

[^2]: The two tools in the table agree on which operation is which.
[Redocly CLI](https://redocly.com/docs/cli/commands/bundle) 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](https://github.com/APIDevTools/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.

[^3]: The [Discriminator Object](https://spec.openapis.org/oas/v3.1.0.html#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.