You chose a reference renderer from its landing page demo. The first real document you load shows your payment method schema as a bare object, with the three variants behind its discriminator gone. The search box finds operations but never a field name. The contract is signed, so every gap is yours to work around.
What you get
You will end up with a scorecard for six candidates, measured against your own worst schema, with the axes a script can score filled in and the rest scored by hand. This is for you if a team is about to commit to one renderer.
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 | 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 | 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 | 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 | 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 | 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 | 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.
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.
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}`)
node worst-schema.mjs specs/ours.yaml
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, 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.
/** 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 }
}
node bakeoff.mjs specs/ours.yaml
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, and the
Swagger UI filter
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,
and Mintlify takes colors and a logo in
docs.json.
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
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 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 and Mintlify renders it on its
platform, with mint dev for a local preview. Price both on the day from their pricing pages,
ReadMe and Mintlify, 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.
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)
})
node --test scorecard.test.mjs
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.
node worst-schema.mjs specs/demo.yaml
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, 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
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.
Footnotes
-
The OpenAPI Specification gives the discriminator two fields, a required
propertyNameand an optionalmapping, and one rule of placement: it is legal only besideoneOf,anyOf, orallOf. 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. ↩︎ Back to text -
The Swagger UI configuration reference describes
filteras 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. ↩︎ Back to text -
The
build-docsreference 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 namesnpx @redocly/cli@v1-archive build-docsas the way to keep the current output. ↩︎ Back to text