# How to choose a renderer for your OpenAPI reference site

> Rank your own schemas by what a renderer can get wrong, build the worst with every renderer you can run, score the rest by hand, and publish the table first.

Source: https://voxgig.com/howto/choose-a-reference-docs-renderer

- Audience: api-producer
- Level: intermediate
- Verified: 2026-10-21
- Published: 2026-10-19

## Short answer

Rank the schemas in your own OpenAPI document and take the worst as the test case for Redoc, Scalar, Swagger UI, Stoplight Elements, ReadMe, and Mintlify. Build the document with the three you can run from `node_modules` and read what each build carries before any script runs. Score search, branding, and access for a private spec by hand, price the hosted two on the same axes, and publish the table before anyone signs.

---
## You will need

Node 22 or later, an OpenAPI 3 document with at least 30 operations, and a browser for the axes
no script can score. Verified 2026-10-21 against Node 22.22.0, `@redocly/cli` 2.65.0,
`@scalar/server-side-rendering` 0.1.60 with `@scalar/api-reference` 1.75.0, `swagger-ui-dist`
5.34.0, and `yaml` 2.9.1. The sample document has fourteen operations and one schema built to
be hard: a `oneOf` of three shapes behind a discriminator with a mapping.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Mintlify](https://www.mintlify.com/docs/api-playground/openapi-setup) | A hosted site where writers work in a UI and the OpenAPI pages sit beside the guides | A subscription, a build that runs on their platform, and a source of truth that moves out of your repository | The reference must build offline in CI, or readers sit on a network with no internet |
| [ReadMe](https://docs.readme.com/main/docs/openapi) | A hosted developer hub with an API explorer, logged-in readers, and each reader's own key shown in the samples | A subscription, an upload step through their CLI, and Markdown additions tied to each `operationId` | You want the rendered reference in version control beside the spec |
| [Redoc](https://redocly.com/docs/redoc) | A static build in CI that prerenders the whole reference into one HTML file | A page that fetches its renderer from Redocly's CDN when it opens, and a theme object that Redoc 3 replaces | Readers need a console that sends requests from the page |
| [Scalar](https://scalar.com/products/api-references/getting-started) | A reader with an API client built in, rendered in the browser or on the server | A 4.6 MB browser bundle to serve, which the server-rendered markup needs before it is interactive, and fonts from Scalar's own host | You want a page with no JavaScript at all |
| [Stoplight Elements](https://docs.stoplight.io/docs/elements/b074dc47b2826-getting-started) | A web component dropped into a page you already have, in a sidebar or stacked layout | A page that is empty until its script runs, and an npm package whose dependency tree runs close to a gigabyte | A crawler or an agent has to read the page without a browser |
| [Swagger UI](https://swagger.io/docs/open-source-tools/swagger-ui/usage/installation/) | The renderer every API developer has seen, with try-it built in and nothing to learn | A page that renders nothing without JavaScript, and a search box that filters by tag text only | Readers need full-text search or a page that carries its text without a script |

The four you can run cost engineering time and leave you the build. Redoc prerenders everything
into one file and then fetches its renderer from a CDN when the page opens. Swagger UI and
Elements ship a page that is empty until their scripts run, and Scalar renders on the server
only if you wire its rendering package into your build. The hosted two remove the build and the
hosting and charge for both. They also move the source of truth out of your repository, so a
spec change becomes a sync rather than a commit.

## Find your worst schema before you open a demo

The demo on a landing page is a small, flat document, and every renderer handles a small, flat
document. The differences live in composition: `oneOf` and `anyOf` variants, a
discriminator with a mapping,[^1] `allOf` merges, a schema that refers to itself, and a type
that can be null.

```yaml title="specs/ours.yaml"
    PaymentMethod:
      description: A payment method is one of three shapes, told apart by its type field.
      oneOf:
        - $ref: '#/components/schemas/Card'
        - $ref: '#/components/schemas/BankTransfer'
        - $ref: '#/components/schemas/Wallet'
      discriminator:
        propertyName: type
        mapping:
          card: '#/components/schemas/Card'
          bank_transfer: '#/components/schemas/BankTransfer'
          wallet: '#/components/schemas/Wallet'
```

`worst-schema.mjs` walks every component schema and gives each a point per thing a renderer has
to lay out and can get wrong. A referenced schema is scored under its own name, so the points
say where the difficulty is, not how often it is reached.

```js title="worst-schema.mjs"
export function score(name, schema) {
  const reasons = []
  let points = 0
  let selfRef = false
  let depthSeen = 0
  const add = (n, why) => {
    points += n
    reasons.push(why)
  }
  const walk = (node, depth, path) => {
    if (!node || typeof node !== 'object') return
    const at = path ? ` at ${path}` : ''
    if (node.$ref) {
      if (refName(node.$ref) === name) selfRef = true
      return
    }
    depthSeen = Math.max(depthSeen, depth)
    for (const k of COMPOSITE) {
      if (Array.isArray(node[k])) add(2 + node[k].length, `${k} with ${node[k].length} branches${at}`)
    }
    if (node.discriminator) {
      const m = Object.keys(node.discriminator.mapping ?? {}).length
      add(3 + m, m ? `discriminator with ${m} mappings${at}` : `discriminator without a mapping${at}`)
    }
    const types = Array.isArray(node.type) ? node.type : node.type ? [node.type] : []
    if (types.includes('null') || node.nullable) add(1, `nullable${at}`)
    if (node.additionalProperties === true || typeof node.additionalProperties === 'object') add(1, `free-form map${at}`)
    if (Array.isArray(node.enum) && node.enum.length > 5) add(1, `enum of ${node.enum.length} values${at}`)
```

```bash
node worst-schema.mjs specs/ours.yaml
```

```text output
specs/ours.yaml: 13 schemas, 9 carry something a renderer can get wrong

 score  schema          why
    11  PaymentMethod   oneOf with 3 branches; discriminator with 3 mappings
     5  BankTransfer    allOf with 2 branches; nullable at allOf[1].bank_name
     5  Card            allOf with 2 branches; enum of 6 values at allOf[1].brand
     5  Event           anyOf with 3 branches at data
     5  Invoice         oneOf with 2 branches at payment_method; nullable at payment_method.oneOf[1]
     5  Wallet          allOf with 2 branches; nullable at allOf[1].email
     4  CustomerGroup   refers to itself
     2  Customer        nullable at name; free-form map at metadata
     2  Error           nullable at details; free-form map at details

score every candidate against PaymentMethod, not against a demo
```

`PaymentMethod` scores eleven: three variants and a discriminator that maps three values to
three schemas. Everything under it is a schema the six will mostly agree on. The last line is
the instruction for the rest of the page.

## Build the same document with every renderer you can run

Three of the six build from `node_modules` with no network. Redoc builds through
[`redocly build-docs`](https://redocly.com/docs/cli/commands/build-docs), Scalar through its
server-side rendering package, and Swagger UI as the HTML shell its installation guide shows,
pointed at `swagger-ui-bundle.js` and the document. The harness builds all three and reads each
result the way a crawler, a link checker, or an agent reads it: as text, before any script runs.

```js title="bakeoff.mjs"
/** Redoc: one HTML file with the document and its rendered state inlined. The renderer itself is not. */
export function buildRedoc(specFile) {
  const file = join(OUT, 'redoc.html')
  execFileSync(process.execPath, [REDOCLY, 'build-docs', specFile, '-o', file], {
    stdio: 'ignore',
    env: { ...process.env, REDOCLY_SUPPRESS_UPDATE_NOTICE: 'true', REDOCLY_TELEMETRY: 'off' },
  })
  const html = readFileSync(file, 'utf8')
  return { renderer: 'Redoc', html, localBytes: 0 }
}
```

```bash
node bakeoff.mjs specs/ours.yaml
```

```text output
specs/ours.yaml: 14 operations, worst schema PaymentMethod with 3 variants

renderer    summaries  variants  HTML bytes  renderer bytes you serve  hosts the page calls
Redoc       14 of 14   3 of 3       172,284                         0  cdn.redocly.com, fonts.googleapis.com
Scalar      14 of 14   3 of 3       706,018                 4,620,361  fonts.scalar.com
Swagger UI  0 of 14    0 of 3           358                 1,871,820  none

summaries and variants: visible as text before any script runs, a variant by its schema
name or its discriminator value. renderer bytes: the files from node_modules the page needs
beside it; Redoc fetches its renderer from the CDN instead. hosts: script and style sheet
tags, and CSS url() and @import. All three builds ran from node_modules with no network.
search, branding, and access for a private document: score by hand, against PaymentMethod
```

Two of the three carry every operation summary and all three variants of `PaymentMethod` before
a line of JavaScript runs, Redoc's under their discriminator values and Scalar's under their
schema names. The Swagger UI page carries none, because the shell is 358 bytes and
everything else happens in the browser. The byte columns say what you serve. Scalar's browser
bundle is 4.6 MB beside a 706 KB page, which loads its fonts from `fonts.scalar.com`, and the
Swagger UI bundle and style sheet are 1.9 MB.
Redoc serves nothing of its own, because the page fetches the renderer from `cdn.redocly.com`
and its fonts from Google when it opens. That bundle is 1,103,471 bytes, measured by hand with a
HEAD request, and it is why a Redoc build is an offline build without being an offline page.

Stoplight Elements is missing from the table because its npm package pulls a dependency tree of
937 MB, measured in a scratch install, which no sample should ask of a reader. Its web
component bundle is 2,084,170 bytes and its style sheet 297,600, read from the same install, and
like Swagger UI it renders everything in the browser.

## Score the axes a script cannot

Open each build in a browser at `PaymentMethod` and look at four things. The three variants
should be offered as a choice, with the mapping names `card`, `bank_transfer`, and `wallet`
visible. The `allOf` behind each variant should merge into one property list, and the null in
`Invoice.payment_method` should survive as a null. Then type `account_last4` into each search
box. It appears in one schema and in no summary, so a search that finds it reads
schemas. Redoc's search indexes each menu entry's name, description, and path, not a schema's
fields. Scalar's search can be switched off with
[`hideSearch`](https://scalar.com/products/api-references/configuration), and the
Swagger UI [`filter`](https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/)
box matches text inside tags and nowhere else.[^2]

Branding is a configuration question with a page for each. Redoc takes theme options and a
custom Handlebars template at build time, and Scalar takes `customCss`. Swagger UI is restyled
through its style sheet and
[plugin system](https://swagger.io/docs/open-source-tools/swagger-ui/customization/overview/),
and Mintlify takes colors and a logo in
[`docs.json`](https://www.mintlify.com/docs/customize/themes).

Access for a private spec is the axis the hosted platforms sell. A self-hosted build is a set of
files, so your reverse proxy guards the page and the spec URL alike, with no help from the
renderer. [Mintlify's authentication](https://www.mintlify.com/docs/deploy/authentication-setup)
covers organization members on every plan. A password needs Pro or Enterprise, OAuth or JWT
needs Enterprise, and every method works only on a custom domain or a Mintlify subdomain.
[ReadMe](https://docs.readme.com/main/docs/custom-login-page) logs readers in through a JWT
redirect from your own login page and can show each one their own key. Neither builds offline.
ReadMe takes the document through
[`rdme openapi upload`](https://docs.readme.com/main/docs/rdme) and Mintlify renders it on its
platform, with `mint dev` for a local preview. Price both on the day from their pricing pages,
[ReadMe](https://readme.com/pricing) and [Mintlify](https://www.mintlify.com/pricing), and put
both sets of rows in one table where the decision is being made.

## Check it worked

The test suite pins what the harness measures: the ranking, what `present` reads as text, the
names a variant appears under, the host list, and the verdict on each build.

```js title="scorecard.test.mjs"
test('the bake-off tells a prerendered page from a shell', async () => {
  const r = await bakeoff('specs/ours.yaml')
  const by = Object.fromEntries(r.rows.map((row) => [row.renderer, row]))
  assert.equal(r.operations, 14)
  assert.deepEqual(r.variants, ['Card', 'BankTransfer', 'Wallet'])
  for (const name of ['Redoc', 'Scalar']) {
    assert.equal(by[name].summaries, 14, `${name} carries every summary before JavaScript runs`)
    assert.equal(by[name].variants, 3, `${name} carries every variant of the worst schema`)
  }
  assert.equal(by['Swagger UI'].summaries, 0, 'the shell carries no operation text until its script runs')
  assert.deepEqual(by.Redoc.hosts, ['cdn.redocly.com', 'fonts.googleapis.com'])
  assert.deepEqual(by.Scalar.hosts, ['fonts.scalar.com'])
  assert.deepEqual(by['Swagger UI'].hosts, [])
  assert.ok(by.Scalar.localBytes > by['Swagger UI'].localBytes)
})
```

```bash
node --test scorecard.test.mjs
```

```text output
1..11
# tests 11
# suites 0
# pass 11
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 3800.086537
```

Eleven pass. Inline the document into the Swagger UI shell's script and `summaries` stays at 0,
because text that only a script carries is not on the page until the script runs.

## When it goes wrong

Every renderer scores the same. The bake-off ran on the vendor's sample, or on a document shaped
like it. The sample's `demo.yaml` is that document, two flat schemas and an enum.

```bash
node worst-schema.mjs specs/demo.yaml
```

```text output
specs/demo.yaml: 2 schemas, 0 carry something a renderer can get wrong
no oneOf, anyOf, allOf, discriminator, self-reference, or nullable type anywhere:
a bake-off on this document cannot tell the renderers apart
```

A document with nothing to get wrong cannot separate the candidates, so a choice made on it is
a choice made on looks.

The harness says Redoc prerenders, but the live site shows a crawler an empty page. The build
was never deployed. Redoc's
[HTML deployment](https://redocly.com/docs/redoc/deployment/html), a `<redoc spec-url>` element
and the CDN script, renders in the browser, and Scalar's CDN script tag does the same. Score the
deployment you will ship, not the best path the renderer has.

The Redoc build prints a notice on every run. Redocly says an upcoming release switches
`build-docs` to Redoc 3, which is themed with CSS custom properties instead of the theme
object, so the branding you scored may need redoing after an upgrade.[^3] Pin the CLI version
in the bake-off and rerun the bake-off when you raise it.

## When not to do this

Do not let this bake-off settle where your guides live. A platform can win the reference and
lose the guides, or the reverse, because the axes differ. Guides need review in pull requests,
versioning, and redirects, while the reference inherits all three from the spec. Decide the
guides toolchain on its own.

Do not run it for a small internal API behind a VPN with ten operations and no variants. Take
Swagger UI or Redoc, deploy the static file, and spend the week elsewhere.

Do not make bytes the deciding axis. A 4.6 MB bundle is fetched once and cached, while a search
box that reads tags only costs every reader on every visit. And do not score on a vendor's
sample, the mistake the first step exists to prevent.

## Related how-tos

- [Organize a large OpenAPI reference by tags and tag groups](/howto/organise-a-large-openapi-reference)

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

- [Add sequence diagrams to reference docs with Mermaid](/howto/add-sequence-diagrams-to-reference-docs)

## Last verified

Verified 2026-10-21 against Node 22.22.0, `@redocly/cli` 2.65.0, `@scalar/server-side-rendering`
0.1.60 with `@scalar/api-reference` 1.75.0, `swagger-ui-dist` 5.34.0, and `yaml` 2.9.1. Every
output block is what the command preceding it printed in the page's code directory after
`npm ci`. The Redoc bundle size came from a HEAD request to `cdn.redocly.com` and the Stoplight
Elements figures from a scratch install of `@stoplight/elements` 9.0.29, both by hand. No
browser was opened, so the hand-scored axes are described and not scored, and ReadMe and
Mintlify are read from their documentation.

[^1]: The [OpenAPI Specification](https://spec.openapis.org/oas/v3.1.0.html#discriminator-object)
gives the discriminator two fields, a required `propertyName` and an optional `mapping`, and
one rule of placement: it is legal only beside `oneOf`, `anyOf`, or `allOf`. It then says that
a discriminator MAY act as a hint to shortcut validation, which is as much as it says about
what a reader should see. The six candidates on this page draw six different pictures of a
hint.

[^2]: The Swagger UI [configuration reference](https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/)
describes `filter` as an edit box in the top bar that filters the tagged operations shown. The
matching is case sensitive and applied anywhere inside the tag. A box in the top bar that
takes typed text is what every reader takes for search, and it reads the tags.

[^3]: The [`build-docs` reference](https://redocly.com/docs/cli/commands/build-docs) says an
upcoming Redocly CLI release switches the command to Redoc 3, and that the command prints a
notice about it on every run. Redoc 3 is themed with CSS custom properties instead of the
Redoc 2 theme object, so theme options and custom templates may need updates. The page names
`npx @redocly/cli@v1-archive build-docs` as the way to keep the current output.