Swagger UI Customization

Swagger UI remains the most widely deployed OpenAPI renderer, and customizing it for a production portal is less about CSS than about discipline: extend through the plugin API, theme with scoped CSS custom properties, and pin swagger-ui-dist to an exact version so an upstream minor release never silently rearranges your build. This guide is part of Developer Portal Frameworks & UI Setup, and it covers the configuration files, the CI pipeline, and the upgrade discipline that keeps a heavily customized Swagger UI maintainable — plus an honest read on when to migrate instead.

Swagger UI’s defining feature is its interactive “try it out” panel backed by a configurable request layer. That interactivity is also its main customization risk: the UI re-renders on spec load and auth changes, so any imperative DOM edits are wiped. The supported path is the plugin system. For deep plugin patterns — auth interceptors, request/response transforms, custom layouts — see Customizing Swagger UI with CSS and plugins. If your customization wishlist keeps fighting the framework, compare against the static-build model in Redocly & OpenAPI UI Configuration and the modern embedded-client model in Scalar & Modern Docs Integration.

Swagger UI customization layers The swagger-ui-dist bundle is extended by plugins and scoped CSS variables, configured in swagger-initializer.js, never by editing source files. swagger-ui-dist do not edit wrapComponents plugin scoped CSS variables swagger-initializer.js SwaggerUIBundle() Extend around the bundle, never inside it

Prerequisites & Environment Setup

You need Node.js 18 or newer, an OpenAPI 3.0 or 3.1 description, and a static host or CDN. Use the swagger-ui-dist package (the prebuilt browser bundle) rather than building Swagger UI from source — it is the path with the most stable surface for plugins and CSS. Install it pinned to an exact v5.x release:

npm install [email protected]

The two files you will own are swagger-initializer.js (where you call SwaggerUIBundle and register plugins) and a custom-styles.css (your scoped theme). Everything else comes from the dist package and must stay untouched, because npm install overwrites it.

Confirm the installed version and that the standalone preset is present, since both are required for the topbar and layout:

npm ls swagger-ui-dist
ls node_modules/swagger-ui-dist/   # expect swagger-ui-bundle.js, swagger-ui-standalone-preset.js

Validate your spec before you wire any UI. Swagger UI will load a structurally broken spec and render it incompletely rather than failing, so a lint gate saves debugging time. @redocly/cli works well here; its rule set is described in Redocly & OpenAPI UI Configuration, and broader rule governance lives in Spec Linting & Governance.

Core Configuration

swagger-initializer.js is the heart of the setup. Register plugins through wrapComponents, which wraps an existing component instead of replacing it — preserving the component’s internal state and lifecycle. The annotated initializer below adds a class to the authorize button and enables deep linking:

// swagger-initializer.js
// A plugin that decorates the authorize button without replacing it.
const CustomAuthPlugin = () => ({
  wrapComponents: {
    authorizeBtn: (Original, system) => (props) =>
      // Use system.React, NOT an imported React, to share the bundled copy.
      system.React.createElement(Original, {
        ...props,
        className: `custom-auth-btn ${props.className || ''}`
      })
  }
});

window.ui = SwaggerUIBundle({
  url: '/openapi.json',          // same-origin spec avoids CORS failures
  dom_id: '#swagger-ui',
  presets: [
    SwaggerUIBundle.presets.apis,
    SwaggerUIStandalonePreset      // required for the topbar + standalone layout
  ],
  plugins: [
    SwaggerUIBundle.plugins.DownloadUrl,
    CustomAuthPlugin
  ],
  deepLinking: true,             // anchor links per operation (needs operationId)
  displayRequestDuration: true,  // show request timing in "try it out"
  tryItOutEnabled: true,
  defaultModelsExpandDepth: 1,
  layout: 'StandaloneLayout'
});

Two configuration points carry the most weight. deepLinking: true only works when every operation has an operationId, so enforce that in your linter. The presets array must include SwaggerUIStandalonePreset for the standalone layout and topbar — omit it and you get the bare reference with no chrome. The wrapComponents pattern is the single most important habit: it is how you change appearance and behavior while keeping the upgrade path intact.

For the theme, drive every brand value through CSS custom properties and scope each rule under .swagger-ui so nothing leaks into the host page:

/* custom-styles.css — loaded in <head> BEFORE the bundle */
:root {
  --swagger-brand-primary: #16306d;
  --swagger-brand-font: 'Inter', system-ui, sans-serif;
}
.swagger-ui .topbar { background: var(--swagger-brand-primary); }
.swagger-ui .info .title { font-family: var(--swagger-brand-font); }
.swagger-ui .opblock-tag { border-left: 4px solid var(--swagger-brand-primary); }

Avoid !important and deeply nested selectors; both create maintenance debt and lose specificity battles after an upgrade. A single scoped class plus custom properties covers almost every branding need.

Integration Pattern

Lint the spec, build the assets, then sync to a CDN with cache headers that match each asset’s volatility. This workflow gates on the lint step and caches hashed assets aggressively while keeping the entry HTML fresh:

# .github/workflows/deploy.yml
name: Deploy Docs
on:
  push:
    branches: [main]
    paths: ['openapi.yaml', 'src/**', 'custom-styles.css']
permissions:
  contents: read
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Validate spec (gate before build)
        run: npx @redocly/cli lint openapi.yaml --format=github

      - name: Build
        run: npm run build   # copies swagger-ui-dist assets + initializer + css into dist/

      - name: Deploy hashed assets (long cache)
        run: >-
          aws s3 sync dist/ s3://docs-bucket/ --delete
          --cache-control "max-age=31536000,immutable"
          --exclude "index.html"
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: Deploy entry HTML (no cache)
        run: >-
          aws s3 cp dist/index.html s3://docs-bucket/index.html
          --cache-control "no-cache"
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

The split deploy is deliberate: versioned/hashed bundle files get a one-year immutable cache, while index.html is served no-cache so a new deploy is picked up immediately. Without that split, users would keep loading a stale entry page that references the previous bundle.

Advanced Options

State plugins for cross-cutting behavior. When a change must react to UI state — for example, injecting a tenant header whenever auth changes — a statePlugins reducer is more robust than wrapComponents, because it hooks the data layer rather than the view. Auth interceptor examples are in Customizing Swagger UI with CSS and plugins.

Request interceptors for auth and tracing. requestInterceptor and responseInterceptor in the SwaggerUIBundle config let you attach bearer tokens, add correlation IDs, or rewrite URLs before a “try it out” call leaves the browser — without touching components at all.

Multi-version portals. Deploy one SwaggerUIBundle instance per URL path, each pointing at a versioned spec, and route between them from a portal nav or an API gateway. Keep one shared custom-styles.css so branding stays consistent across versions, and a compatibility matrix recording which plugin versions were tested against which swagger-ui-dist release.

Three customization surfaces, in order of cost

Swagger UI can be adjusted through configuration, through the plugin system, and through CSS, and the three differ enormously in how well they survive an upgrade. Configuration options are a documented interface the project maintains. Plugins hook into defined extension points and are reasonably stable. CSS that targets generated class names is implementation detail, and when it stops matching it produces no error at all — the page simply reverts to looking the way it did before your stylesheet existed.

Customization surfaces by durability Configuration options are maintained, plugins hook defined extension points, and CSS targeting generated class names silently stops matching after an upgrade. reach for the leftmost surface that can express what you need configuration layout, expansion, auth, try-it behaviour documented and maintained plugins wrap components, intercept requests defined extension points CSS on generated classes appearance beyond the above names change without notice fails silently on upgrade

The silent-failure property of the third column is what makes it worth avoiding rather than merely worth noting. A configuration option that is removed produces a warning; a plugin against a changed extension point usually throws. A selector that no longer matches produces a page that renders perfectly and looks wrong, which nobody notices until a reader mentions it — and by then the cause is several upgrades back.

What customization cannot do

There is one thing worth being explicit about because teams reach for CSS to achieve it and it does not work: hiding operations. Swagger UI renders whatever document you hand it, and that document is fetched by the browser. An operation hidden with a CSS rule is still in the JSON the reader already downloaded, still visible in the network tab, and still readable by requesting the specification URL directly.

Hidden is not removed A CSS rule hides an operation from the rendered page while leaving it in the document the browser downloaded; only build-time filtering removes it. hidden with CSS not shown on the page still in the downloaded document published, whatever the page shows filtered at build time never enters the artifact nothing to find in the source actually removed

The distinction matters because the operations people want to hide are usually the ones it matters most not to publish — admin routes, debug endpoints, features not yet announced. Hiding internal endpoints in Swagger UI covers filtering the document before it ships, which is the only approach that does what people intend.

Verification & Testing

Reproduce the CI gate locally and confirm exit codes before pushing:

npx @redocly/cli lint openapi.yaml
echo "lint exit: $?"   # 0 = ok / warnings only; 1 = errors block the build

npm run build
test -f dist/index.html && test -f dist/swagger-ui-bundle.js && echo "assets present"

Serve the build and verify in a browser that the topbar uses your brand color, the authorize button carries your custom class, and a “try it out” request executes against a test server. A static check confirms the page loads a same-origin spec rather than a cross-origin URL that may hit CORS:

npx http-server dist -p 8080 &
curl -s http://localhost:8080/swagger-initializer.js | grep -q "/openapi.json" \
  && echo "same-origin spec" || echo "check spec URL"

After deploy, fetch the live entry page and confirm the cache header is no-cache, so users always get the current bundle reference: curl -sI https://docs.example.com/ | grep -i cache-control.

Troubleshooting

Customizations vanish after spec load or login. You modified the DOM imperatively with querySelector, and Swagger UI re-rendered over it. Replace the imperative code with a wrapComponents or statePlugins plugin so the change is part of the render tree and survives re-renders.

Theme breaks after a minor swagger-ui-dist bump. Minor releases change internal class names and component structure, so selectors targeting them stop matching. Pin the exact version in dependencies and overrides, review the changelog, and prefer CSS custom properties over deep selectors that depend on internal markup.

“Try it out” fails with a CORS error. The UI is fetching the spec or calling the API across origins without Access-Control-Allow-Origin. Bundle the spec to the same origin with npx @redocly/cli bundle openapi.yaml and serve it locally, and configure CORS on the API for live calls.

Brand styles overridden by the host page. Unscoped or low-specificity rules lose to the surrounding site CSS. Scope every rule under .swagger-ui, avoid !important, and load custom-styles.css in <head> before the Swagger UI bundle so your variables are defined when the bundle initializes.

Configuration first, and the options worth knowing

Most of what teams reach for CSS to achieve is available as a supported option, and the list is longer than it appears from a first reading. Layout and expansion behaviour, whether the try-it panel is present at all, whether credentials persist between visits, which HTTP snippet languages are offered, how deeply models expand by default, and whether operations are sorted or left in document order are all configuration rather than styling. Reading the full option list once, before writing any custom code, reliably removes several items from a customization backlog.

Two options deserve to be set deliberately on every deployment rather than left at their defaults. Credential persistence writes whatever a reader types into browser storage, where it outlives the tab and is readable by any script on the origin — appropriate for a purely internal tool, wrong for anything public. And the default expansion state determines whether a large API opens as a wall of expanded operations or as a scannable list, which matters more for perceived quality than most styling decisions.

Plugins for behaviour, not appearance

The plugin system exists to change what the interface does, and it is the right tool when the requirement is behavioural: rewriting a request before it is sent, adding a panel, wrapping a component to add an annotation, or reacting to an authorization event. It is the wrong tool for appearance, where configuration or a stylesheet is simpler and easier to reason about.

The distinction matters because plugins run inside the renderer’s component tree and therefore see its internals. A plugin that wraps a documented component and adds behaviour is durable; one that reaches into a component’s internal state to change how something looks is as fragile as a class-name selector and considerably harder to debug when it breaks. Keep the set small, keep each one focused on a behaviour you can describe in a sentence, and re-test them after every renderer upgrade — the same discipline that applies to templates in a generator, and for the same reason.

FAQ

Can I use React components inside Swagger UI?

Yes, through wrapComponents or a custom plugin that renders JSX in the React context SwaggerUIBundle provides. Use the system.React passed to the plugin rather than importing React separately, so you avoid version conflicts with the bundled copy.

Is it safe to edit swagger-ui-dist source files?

No. Any direct edit is overwritten on the next npm install. Use the plugin API or external CSS and JS injection so your customizations survive upgrades.

How do I pin swagger-ui-dist so a minor release does not break my theme?

Set an exact version in dependencies and repeat it under overrides so transitive copies resolve to the same release. Review the changelog before bumping, because minor releases can change internal class names and component structure.

How do I validate the spec before building the UI?

Run npx @redocly/cli lint openapi.yaml in CI before the asset build. It catches missing operationIds, broken $refs, and schema type errors that would otherwise produce a UI that loads but renders incorrectly.

A final note on scale, because it changes which of these decisions matter. Swagger UI renders the entire specification in the browser, so a small API is fast regardless of configuration while a large one is dominated by parse and render time. Past a few hundred operations the settings that matter most are the ones that reduce work rather than the ones that change appearance: collapsing operations by default, limiting model expansion depth, and — where the audience genuinely divides along those lines — publishing separate documents per audience rather than one that everybody downloads in full.

That last option is worth considering earlier than most teams do. Splitting a large specification into a public document and an internal one, or per product area, reduces what every reader loads, makes navigation shorter, and has the incidental benefit of keeping internal operations out of the public artifact entirely. It costs a filtering step in the build and removes a class of both performance and disclosure problems at once.

Taken together, these choices describe a renderer that is heavily used and lightly customised — which is the configuration that survives upgrades, keeps working as the specification grows, and needs the least attention from the team that owns it.

Both of those decisions are easier to take early, while the specification is small enough that splitting it is a refactor rather than a migration, and while nobody has yet written a stylesheet against class names that are about to change.

Deferring them is not neutral: each one gets harder in proportion to how much has been built on top of the current arrangement.

The result is a renderer that stays boring to operate, which is exactly what you want from the part of a portal that has to keep working unattended.

Anything more elaborate should be a conscious exception with a note explaining what it buys.

The exceptions are where the interesting work is; the defaults are where the reliability comes from.