ReDoc vs Swagger UI
You have a valid OpenAPI document and now you have to decide what a paying customer sees when they open your docs URL. Part of the Documenting APIs with OpenAPI guide, this page settles the ReDoc versus Swagger UI question with the criteria that actually move revenue: how fast a trial user gets a real 200 back, how readable your reference stays at two hundred operations, what the page costs to serve, and how much of your brand survives the theming layer.
Both renderers read the same spec, so this is not a correctness question. FastAPI already wires both up for free at /docs and /redoc, which is precisely why most builders never think about it and ship whichever default they saw first. That default costs you either conversions or support tickets, depending on which one you left on. The right answer for a commercial API is usually "both, on purpose, with different jobs" — and the rest of this page shows how to make that cheap.
Two renderers with two different jobs
Swagger UI is an execution surface. It renders each operation as a collapsible card with a Try it out button that fires a real fetch() from the reader's browser to your live API. That means a prospect can paste a key, hit send, and see a real response body inside sixty seconds of landing on your site. It also means your docs page generates authenticated, billable, CORS-preflighted traffic against production, and that your customer's key now lives in a browser tab on a page you rendered.
ReDoc is a reading surface. It renders a three-pane reference — navigation on the left, prose and schemas in the middle, request and response samples on the right — and it never calls your API. It gives you generated curl and language samples the reader copies into their own terminal, deep-linkable anchors for every operation, and a schema viewer that expands nested $ref chains without collapsing into noise. Nothing executes, so nothing needs CORS, nothing burns quota, and nothing leaks a key into a page you control.
That single difference cascades into everything else. Swagger UI has to ship an HTTP client, an OAuth2 flow handler, a request builder, and a response renderer, so its bundle is bigger and its DOM is heavier. ReDoc only has to render, so it can virtualize aggressively and stay smooth on a spec that would make Swagger UI's accordion crawl. Pick based on which job your reader is doing, not on which page looks nicer in a screenshot.
The side-by-side that actually matters
Feature matrices for docs renderers usually list twenty rows nobody weighs. Five rows decide this.
| Dimension | Swagger UI | ReDoc |
|---|---|---|
| Live try-it-out | Built in | None |
| Long-spec readability | Degrades | Virtualized panes |
| First load, gzipped | ~350 KB | ~310 KB |
| Theming | CSS overrides | Options object |
| OAuth2 flow in page | Yes | No |
Bundle weight is close enough that it should not decide anything on its own. Swagger UI ships roughly 330 KB of gzipped JavaScript plus a 20 KB stylesheet; ReDoc's standalone build lands near 310 KB with styles inlined, and jumps past Swagger UI if you leave FastAPI's Google Fonts loading enabled. Both are static assets you can cache for a year at the edge, so the marginal serving cost of either is effectively zero once you stop proxying them through your Python process.
What differs is what happens after parse. Swagger UI builds DOM for every operation in the accordion. On a spec with two hundred operations that is a slow first paint and a janky scroll on mid-range phones. ReDoc virtualizes the middle pane and keeps a searchable index in the sidebar, so it stays responsive at spec sizes where Swagger UI starts to feel broken. If your surface is under thirty operations you will never notice; if you are heading toward a broad public API, ReDoc wins the readability axis outright.
Serving both from one FastAPI app
Stop using FastAPI's built-in /docs and /redoc routes in production. They hardcode a public CDN, they cannot be gated, and they give you no place to inject brand assets. Turn them off in the constructor and register your own routes. Driving the choice from an environment variable also lets you ship try-it-out in staging and lock it down in production without a code change.
# app/docs.py
import os
from fastapi import Depends, FastAPI
from fastapi.openapi.docs import get_swagger_ui_html
from app.docs_redoc import mount_redoc # defined in the next snippet
API_TITLE = os.getenv("API_TITLE", "Forecast API")
DOCS_MODE = os.getenv("DOCS_MODE", "both")
ASSET_BASE = os.getenv("DOCS_ASSET_BASE", "/static/docs")
SWAGGER_PATH = os.getenv("SWAGGER_PATH", "/docs")
app = FastAPI(
title=API_TITLE,
docs_url=None,
redoc_url=None,
openapi_url=os.getenv("OPENAPI_PATH", "/openapi.json"),
)
SWAGGER_PARAMS = {
"defaultModelsExpandDepth": -1,
"docExpansion": os.getenv("SWAGGER_DOC_EXPANSION", "none"),
"persistAuthorization": True,
"displayRequestDuration": True,
"tryItOutEnabled": os.getenv("SWAGGER_TRY_IT_OUT", "true") == "true",
}
def _mount_swagger(target: FastAPI) -> None:
@target.get(SWAGGER_PATH, include_in_schema=False)
async def swagger_ui(_: None = Depends(require_docs_viewer)):
return get_swagger_ui_html(
openapi_url=target.openapi_url or "/openapi.json",
title=f"{API_TITLE} playground",
swagger_js_url=f"{ASSET_BASE}/swagger-ui-bundle.js",
swagger_css_url=f"{ASSET_BASE}/swagger-ui.css",
swagger_favicon_url=f"{ASSET_BASE}/favicon.png",
swagger_ui_parameters=SWAGGER_PARAMS,
)
def register_docs(target: FastAPI) -> None:
match DOCS_MODE:
case "swagger":
_mount_swagger(target)
case "redoc":
mount_redoc(target)
case "both":
_mount_swagger(target)
mount_redoc(target)
case "off":
return
case unknown:
raise ValueError(f"unsupported DOCS_MODE: {unknown!r}")
require_docs_viewer is your own dependency. In production it should check a session cookie or a signed link; in development it returns immediately. defaultModelsExpandDepth: -1 removes the giant schema dump Swagger UI appends below every page, which is the single fastest readability win available. docExpansion: "none" collapses the operation list so a fifty-endpoint API opens as a scannable index instead of a wall.
The ReDoc half is where you get real control, because ReDoc takes a JavaScript options object rather than a stylesheet. Build the HTML yourself and feed it a theme sourced from environment variables so staging, production, and a white-labelled partner deployment all render from one template.
# app/docs_redoc.py
import json
import os
from string import Template
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
ASSET_BASE = os.getenv("DOCS_ASSET_BASE", "/static/docs")
REDOC_PATH = os.getenv("REDOC_PATH", "/reference")
REDOC_OPTIONS = {
"theme": {
"colors": {"primary": {"main": os.getenv("BRAND_PRIMARY", "#2563eb")}},
"typography": {
"fontFamily": os.getenv("BRAND_FONT", "system-ui, sans-serif"),
"code": {"fontFamily": os.getenv("BRAND_MONO", "ui-monospace, monospace")},
},
},
"expandResponses": os.getenv("REDOC_EXPAND_RESPONSES", "200,201"),
"hideDownloadButton": os.getenv("REDOC_HIDE_DOWNLOAD", "false") == "true",
"requiredPropsFirst": True,
"sortTagsAlphabetically": True,
}
_PAGE = Template(
"""<!doctype html>
<html><head><meta charset="utf-8"><title>$title</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>body { margin: 0; padding: 0; }</style>
</head><body>
<div id="redoc"></div>
<script src="$js_url"></script>
<script>Redoc.init($spec_url, $options, document.getElementById("redoc"));</script>
</body></html>"""
)
def mount_redoc(target: FastAPI) -> None:
@target.get(REDOC_PATH, include_in_schema=False)
async def reference() -> HTMLResponse:
html = _PAGE.substitute(
title=f"{os.getenv('API_TITLE', 'API')} reference",
js_url=f"{ASSET_BASE}/redoc.standalone.js",
spec_url=json.dumps(target.openapi_url or "/openapi.json"),
options=json.dumps(REDOC_OPTIONS),
)
return HTMLResponse(html, headers={"Cache-Control": "public, max-age=300"})
Two routes, one spec, no duplicated content. /docs becomes the playground you link from your signup email; /reference becomes the canonical, linkable documentation you point search engines and enterprise integrators at. Both belong inside the wider surface described in creating a developer portal for your API.
Self-host the assets, always
FastAPI's defaults pull both bundles from a public CDN. That is one uptime dependency, one privacy disclosure, and one supply-chain hole you do not need. If that CDN serves a compromised build, your docs page executes attacker code in a tab where your customers just pasted live API keys. Vendor the files at build time, pin them by version in a manifest, and serve them from your own static mount or object storage.
# scripts/vendor_docs_assets.py
import asyncio
import os
import tomllib
from pathlib import Path
import httpx
MANIFEST = Path(os.getenv("DOCS_ASSET_MANIFEST", "docs-assets.toml"))
TARGET_DIR = Path(os.getenv("DOCS_ASSET_DIR", "static/docs"))
async def _fetch(client: httpx.AsyncClient, name: str, url: str) -> tuple[str, int]:
response = await client.get(url)
response.raise_for_status()
(TARGET_DIR / name).write_bytes(response.content)
return name, len(response.content)
async def main() -> None:
manifest = tomllib.loads(MANIFEST.read_text(encoding="utf-8"))
TARGET_DIR.mkdir(parents=True, exist_ok=True)
timeout = httpx.Timeout(float(os.getenv("DOCS_FETCH_TIMEOUT", "30")))
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
results = await asyncio.gather(
*(_fetch(client, name, url) for name, url in manifest["assets"].items())
)
for name, size in sorted(results):
print(f"{name}: {size / 1024:.1f} KiB")
if __name__ == "__main__":
asyncio.run(main())
The manifest is four lines of TOML holding one pinned URL per file, so upgrading a renderer is a reviewable diff rather than a silent change to what your customers execute. Mount the directory with StaticFiles and set an immutable, one-year Cache-Control on the versioned path. Serving those bytes from your Python workers instead of a CDN or object storage is the one place this decision touches your hosting bill, and the fix costs nothing — see deploying APIs to Render or Vercel for the static routing patterns.
When to choose each
Choose Swagger UI when the reader's next action is a request, not a decision. Pre-revenue APIs, internal tools, developer-facing utilities with under thirty operations, and anything sold on "try it right now" all convert better with an executable page. Choose ReDoc when the reader is implementing against you for a week: broad public APIs, anything with deeply nested schemas, anything an enterprise procurement team will read before signing, and anything you want indexed cleanly by search.
Avoid Swagger UI as your only page once your operations count passes roughly fifty, once any endpoint mutates billed state, or once you serve customers in regulated environments where an in-browser key paste is a finding. Avoid ReDoc as your only page while you are still selling the concept, because removing the try-it-out button from a self-serve funnel measurably lengthens the path from landing to first successful call. If a live playground would fire real charges, keep it pointed at a sandbox base URL and make the environment switch obvious.
Migrating from one to the other
Switching renderers is not a rewrite, because the spec is the interface and neither tool touches it. The only real work is redirects and analytics. Add the new route alongside the old one, keep both live for a release, then decide with data rather than taste — and if the old path was public and indexed, treat retiring it the same way you would treat deprecating an API endpoint without breaking customers: announce it, redirect it, never delete it silently.
Step three is the one builders skip. Emit a lightweight event on each docs page render and store it next to your other product events, the way tracking API usage and analytics describes, then compare which page precedes a first successful authenticated call. That number, not a preference, tells you which renderer earns its place on your primary docs URL. If the split is genuinely even, keep both — the marginal cost is one static file and one route.
One caveat worth planning for: if your spec relies on OpenAPI 3.1 features such as null in a type union, webhooks, or complex oneOf discriminators, render it in both tools before you commit. Support has historically lagged in one renderer or the other, and the failure mode is a silently blank schema block rather than an error. The metadata work in customizing the FastAPI OpenAPI schema is what makes either page look professional, so do that first and pick the renderer second.
Builder verdict
Make ReDoc your canonical reference and keep Swagger UI as a gated playground. ReDoc gives you a page that stays fast and readable as your surface grows, themes cleanly from environment variables so a white-label deployment costs nothing, and never turns a docs visit into billed traffic or a key pasted into a browser tab. It is the page you want ranking, linked from your pricing page, and read by the engineer who has to justify your invoice. Swagger UI still earns its keep in the funnel: nothing converts a skeptical trial user like a green 200 they triggered themselves, so serve it at /docs, point it at a sandbox base URL, and put it behind whatever login your free tier already has. Two routes over one spec is maybe forty lines of Python and one vendored asset directory — far cheaper than picking wrong and rewriting your docs story after your first enterprise customer asks for a PDF-able reference.
FAQ
Does serving a live try-it-out console cost me real money? Yes, and it is easy to underestimate. Every Try it out click is a billed request against your own infrastructure, plus any third-party API you proxy. A docs page that gets a thousand visits a month with a ten percent click rate adds a hundred real calls — trivial for a cached read endpoint, painful if each one triggers an LLM call or a paid data lookup. Point the playground at a sandbox server with capped quotas and it costs cents.
Which renderer is better for organic search traffic to my docs? ReDoc, by a clear margin. Its deep-linkable anchors, static-looking markup, and description-first layout give crawlers real prose per operation, and it stays fast on mobile at large spec sizes. Swagger UI's collapsed accordion hides most of your content behind interaction and renders thin pages that rank poorly.
How risky is loading either bundle from a public CDN? Risky enough to fix in an afternoon. A compromised or unavailable CDN takes your documentation offline or executes untrusted JavaScript on a page where customers paste live credentials. Vendor pinned copies, serve them from your own static path, and treat a renderer upgrade as a reviewed dependency bump. If keys have ever been pasted into a page you no longer trust, follow your process for rotating API keys without downtime.
What does it cost to switch renderers later? Almost nothing in code and a little in traffic. The spec does not change, so the work is one new route, a vendored asset, and a redirect. The real cost is any inbound links and search rankings pointing at the retired path, which is why you should redirect rather than delete and keep the old URL alive for at least a full release cycle.
Can I keep the playground private without building a login? Yes. Gate the route with a dependency that checks a signed, expiring token in the query string, generate those links from your dashboard or onboarding email, and leave the ReDoc reference fully public. That keeps your marketing surface open, keeps the executable surface tied to a known account, and takes about twenty lines with the auth patterns you already have.
Related
- Documenting APIs with OpenAPI — the parent guide covering spec metadata, versioned export, and client generation.
- Customizing the FastAPI OpenAPI Schema — fix operation IDs, examples, and tags before you argue about renderers.
- Creating a Developer Portal for Your API — where these two docs routes sit inside the wider self-serve surface.
- Versioning and Evolving Public APIs — publishing a stable spec per version so both renderers stay accurate.
- Setting Up FastAPI — the app configuration these docs routes attach to.