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.

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.

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.