How-to › Document and support developers

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.

Audience
API producer
Level
intermediate
Topic
Publish reference docs
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
MintlifyA hosted site where writers work in a UI and the OpenAPI pages sit beside the guidesA subscription, a build that runs on their platform, and a source of truth that moves out of your repositoryThe reference must build offline in CI, or readers sit on a network with no internet
ReadMeA hosted developer hub with an API explorer, logged-in readers, and each reader’s own key shown in the samplesA subscription, an upload step through their CLI, and Markdown additions tied to each operationIdYou want the rendered reference in version control beside the spec
RedocA static build in CI that prerenders the whole reference into one HTML fileA page that fetches its renderer from Redocly’s CDN when it opens, and a theme object that Redoc 3 replacesReaders need a console that sends requests from the page
ScalarA reader with an API client built in, rendered in the browser or on the serverA 4.6 MB browser bundle to serve, which the server-rendered markup needs before it is interactive, and fonts from Scalar’s own hostYou want a page with no JavaScript at all
Stoplight ElementsA web component dropped into a page you already have, in a sidebar or stacked layoutA page that is empty until its script runs, and an npm package whose dependency tree runs close to a gigabyteA crawler or an agent has to read the page without a browser
Swagger UIThe renderer every API developer has seen, with try-it built in and nothing to learnA page that renders nothing without JavaScript, and a search box that filters by tag text onlyReaders 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.

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

  1. The OpenAPI Specification 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. ↩︎ Back to text

  2. The Swagger UI configuration reference 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. ↩︎ Back to text

  3. The build-docs reference 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. ↩︎ 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.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.