Stoplight Elements: Render OpenAPI as Interactive Docs

Stoplight Elements turns an OpenAPI document into a fully interactive, three-pane API reference with a built-in request console, served as a framework-agnostic web component or a React package. This guide is part of Developer Portal Frameworks & UI Setup and covers the <elements-api> element, the apiDescriptionUrl, router, and layout props, and how to host or embed the reference so docs rebuild automatically when the spec changes.

Key objectives:

  • Mount <elements-api> from a CDN or a bundled @stoplight/elements install
  • Point it at a spec with apiDescriptionUrl (or inline apiDescriptionDocument)
  • Choose the right router and layout for standalone pages versus embedded panes
  • Deploy a static reference from CI and verify it renders without console errors

For a deeper, framework-specific walkthrough, see Embedding Stoplight Elements in React.

Stoplight Elements rendering flow An OpenAPI document is fetched by apiDescriptionUrl, parsed into a navigation tree, and rendered by the elements-api web component into a sidebar, content, and Try It console. openapi.yaml apiDescriptionUrl <elements-api> parse + route Rendered reference sidebar content Try It console

Prerequisites & Environment Setup

Stoplight Elements is a client-side renderer. It needs a valid OpenAPI document reachable over HTTP and a place to serve static HTML and JavaScript. There is no server runtime: the component fetches the spec in the browser and renders it.

Requirements:

  • Node.js 20 LTS if you bundle Elements with a build tool. The CDN approach needs no Node at build time, only a static host.
  • A valid OpenAPI 2.0, 3.0, or 3.1 document. Validate it before shipping so render failures surface in CI, not in production.
  • CORS access to the API if you want the Try It console to send live requests from the docs origin to the API origin.

Install the package when bundling into an app or static-site build:

npm install @stoplight/[email protected]

Pin the version. Elements ships frequent releases, and the bundled stylesheet and DOM structure can change between minor versions, which breaks custom CSS overrides if you float on latest.

Add a lint step so a malformed spec never reaches the renderer:

npx @redocly/cli@2 lint openapi.yaml

Elements renders a blank or partial page when the spec has unresolved $refs or missing required fields, with only a vague console warning. Linting first converts that silent failure into a hard CI error. The same discipline applies across renderers — see Redocly & OpenAPI UI Configuration for the equivalent gate in a Redoc pipeline.

Core Configuration

The simplest integration loads the web component and stylesheet from a CDN and mounts a single <elements-api> element. The component is a custom element registered by the Elements bundle; once the script loads, the browser upgrades the tag.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>API Reference</title>
  <!-- Pin the version in the URL for reproducible rendering -->
  <link
    rel="stylesheet"
    href="https://unpkg.com/@stoplight/[email protected]/styles.min.css"
  />
  <script src="https://unpkg.com/@stoplight/[email protected]/web-components.min.js"></script>
</head>
<body style="height: 100vh; margin: 0;">
  <elements-api
    apiDescriptionUrl="/openapi.yaml"
    router="hash"
    layout="sidebar"
  ></elements-api>
</body>
</html>

The three attributes do the heavy lifting:

  • apiDescriptionUrl is the URL the component fetches and parses. It accepts a relative or absolute path to a JSON or YAML OpenAPI document. For specs assembled at build time, fetch the bundled output (a single dereferenced file) rather than a multi-file spec with external $refs, which the browser fetcher cannot always follow.
  • router controls how navigation maps to the URL. hash stores the active operation after # and needs no server config — the safest default for static hosts. history produces clean paths but requires the host to rewrite unknown routes to index.html. memory keeps navigation entirely in component state and changes no URL, which is what you want for an embedded pane.
  • layout is either sidebar (a left navigation tree with a content pane, the default for full-page references) or stacked (a single scrolling column, better for narrow embeds and mobile).

To inline the spec instead of fetching it — useful when the spec is generated server-side or you want to avoid a second network request — use apiDescriptionDocument with a JSON string, or set the property in JavaScript:

<elements-api id="docs" router="hash" layout="sidebar"></elements-api>
<script>
  const el = document.getElementById('docs');
  fetch('/openapi.json')
    .then((r) => r.json())
    .then((spec) => { el.apiDescriptionDocument = spec; });
</script>

Setting the property in JS (rather than the attribute) avoids escaping a large JSON blob into HTML and lets you transform the spec — for example, stripping internal endpoints — before handing it to the renderer.

Other commonly used attributes: hideTryIt removes the interactive request panel for read-only public docs; hideSchemas collapses model definitions; tryItCredentialsPolicy (omit, include, or same-origin) controls whether the console sends cookies; and logo sets a custom mark in the sidebar header.

Integration Pattern

For a production portal, build the static reference in CI so it redeploys whenever the spec merges. The pattern: lint the spec, bundle it into a single dereferenced file, copy the Elements assets and an HTML shell, then publish.

# .github/workflows/elements-docs.yml
name: Build Stoplight Elements Docs
on:
  push:
    branches: [main]
    paths: ['openapi.yaml', 'docs/index.html']
permissions:
  contents: read
  pages: write
  id-token: write
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - name: Lint OpenAPI spec
        run: npx @redocly/cli@2 lint openapi.yaml
      - name: Bundle to a single dereferenced file
        run: npx @redocly/cli@2 bundle openapi.yaml --dereferenced --output dist/openapi.json
      - name: Assemble static site
        run: |
          mkdir -p dist
          cp docs/index.html dist/index.html
      - name: Upload Pages artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: dist/
  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v4

The index.html in docs/ is the shell from the Core Configuration section, with apiDescriptionUrl="/openapi.json" pointing at the bundled artifact the workflow writes to dist/. Bundling with --dereferenced flattens every $ref into one file so the browser never has to chase external references — the single most common cause of partial renders on static hosts.

If you deploy to GitHub Pages under a project subpath (/your-repo/), keep router="hash". The history router would generate paths the Pages host cannot resolve without a custom 404-to-index rewrite.

Advanced Options

Embedding inside React with @stoplight/elements. The package exports an <API> component. Import the component and its stylesheet, then pass props that mirror the web-component attributes (camelCased). Use router="memory" so Elements does not fight the host app’s router:

import { API } from '@stoplight/elements';
import '@stoplight/elements/styles.min.css';

export function ApiReference() {
  return (
    <API
      apiDescriptionUrl="/openapi.json"
      router="memory"
      layout="sidebar"
    />
  );
}

The full Next.js dynamic-import pattern, which avoids SSR errors from the browser-only bundle, is covered in Embedding Stoplight Elements in React.

Theming with CSS variables. Elements exposes design tokens as CSS custom properties scoped to the component. Override them in a stylesheet loaded after styles.min.css:

.sl-elements {
  --color-primary: #16306d;
  --color-primary-dark: #1f3473;
  --font-prose: 'Inter', system-ui, sans-serif;
  --font-mono: 'JetBrains Mono', monospace;
}

Target the documented --color-* and --font-* tokens rather than internal sl-* class names, which change between releases. For a portal that supports a theme toggle, drive these variables from the same token set as the rest of the shell — see Multi-Theme & Dark Mode Support.

Multiple versions on one host. Build a separate bundled spec and HTML shell per version into dist/v1/ and dist/v2/, then add a root landing page that links to each. Each shell’s apiDescriptionUrl points at the bundle in its own directory, so versions stay isolated and cacheable.

An embedded component, not a site

The distinguishing property of Elements is that it is a component rather than a portal. It renders a reference inside a page you already own, which is exactly right when the surrounding site exists — a marketing site, an internal platform console, a React application with its own navigation — and is not a documentation framework you are willing to adopt.

Where Elements sits The host application owns routing, navigation and authentication while the component owns only the reference region of the page. your application — routing, navigation, auth, layout your nav and pages <elements-api> owns the reference region only

That division has one consequence worth stating plainly: everything outside the component stays your responsibility, including authentication. The component fetches a specification URL and renders what comes back — it cannot enforce access, because anything it checked would run in the reader’s browser. Gating belongs in front of the origin, and it must cover the specification URL as well as the page, since those are two separate requests and protecting only the first leaves the contract readable by anyone who asks for it directly.

Routing is the other integration decision

The second thing the host application and the component must agree on is routing. Elements can drive navigation through the URL hash or through history, and the right choice depends entirely on what the surrounding application already does. Hash routing is inert — the host router never sees a hash change, so nothing conflicts — which makes it the safe default when embedding into an application you do not want to modify. History routing produces cleaner, linkable URLs and requires the host router to yield the reference’s path prefix, which is a deliberate configuration change rather than a drop-in.

Routing choices for an embedded reference Hash routing never conflicts with the host router, while history routing gives cleaner URLs and requires the host to yield a path prefix. hash routing /docs#/operations/listInvoices host router never sees it safe default when embedding history routing /docs/operations/listInvoices host must yield the prefix cleaner URLs, real configuration

Decide this before people start linking to operation pages. Switching routing modes later changes every deep link into the reference, and those links live in tickets, chat threads, and other teams’ documentation where you cannot update them.

Verification & Testing

After every build, confirm the reference actually rendered rather than failing silently. Serve the output and check for the rendered DOM:

npx http-server dist/ -p 8080
# in another terminal:
curl -s http://localhost:8080/ | grep -q "elements-api" && echo "shell present"

A curl check only proves the shell shipped, not that the spec parsed. Elements renders client-side, so add a headless browser assertion in CI to catch parse failures:

npx [email protected] install --with-deps chromium
node -e "
const { chromium } = require('playwright');
(async () => {
  const b = await chromium.launch();
  const p = await b.newPage();
  const errors = [];
  p.on('pageerror', (e) => errors.push(e.message));
  await p.goto('http://localhost:8080/');
  await p.waitForSelector('.sl-elements', { timeout: 15000 });
  await b.close();
  if (errors.length) { console.error(errors); process.exit(1); }
  console.log('Elements rendered with no page errors');
})();
"

The waitForSelector('.sl-elements') call fails the build if the component never mounted — the reliable signal that the spec was unparseable or the bundle failed to load. The pageerror listener catches runtime exceptions that would otherwise leave a blank page in production.

Troubleshooting

  • Blank page, console shows “Failed to fetch” or a CORS error. apiDescriptionUrl points at an origin that does not allow the docs origin. Either serve the spec from the same origin as the docs (bundle it into dist/ as in the workflow above) or add Access-Control-Allow-Origin for the docs domain on the spec host.
  • Cannot resolve reference / partial render with missing schemas. The spec has external $refs the browser fetcher cannot follow. Bundle with npx @redocly/cli@2 bundle openapi.yaml --dereferenced --output openapi.json and point apiDescriptionUrl at the single output file.
  • window is not defined during a Next.js or Astro build. The Elements bundle is browser-only and ran during SSR. Load it with a client-only dynamic import (next/dynamic with ssr: false, or an astro:client directive) so it executes only in the browser.
  • Try It console returns 401 even with credentials entered. tryItCredentialsPolicy defaults to omit, so cookies are not sent. Set tryItCredentialsPolicy="include" for cookie-based auth, and confirm the API returns Access-Control-Allow-Credentials: true.

Loading the specification yourself

The default arrangement — handing the component a URL and letting it fetch — is the right one for a public reference and the wrong one almost everywhere else. As soon as the specification is gated, versioned, or served from somewhere the component cannot reach with a plain request, you want to perform the fetch in your own code and pass the resulting document to the component directly.

Doing so buys three things that are awkward to add afterwards. You control the request, so a session cookie or an authorization header actually accompanies it. You see the response, so a redirect to a sign-in page or an expired session becomes something you can detect and act on rather than an empty region with no explanation. And you decide what happens on failure, which is the difference between a reader seeing “the reference could not be loaded, sign in again” and seeing nothing at all.

That last point is worth emphasising because the component’s failure mode is unhelpfully quiet. Given something that is not a specification — an HTML sign-in page, a JSON error, an empty body — it renders nothing and reports nothing. Any embedding that might encounter an interrupted fetch needs an explicit timeout and a visible fallback, or readers will report “the docs are broken” with no further detail and you will have nothing to go on.

Keeping the embed current

Because the component is a script and a specification rather than a build, it is easy for both to drift. The script referenced from a CDN without a version resolves to whatever is current, so the reference can change appearance or behaviour without any commit in your repository. Pin it. The specification, if fetched from a URL, can be cached by a browser or a CDN long past a deploy, so a reader sees operations that no longer exist. Serve it with a short lifetime and an entity tag, and treat it as the one asset worth purging explicitly on every release.

FAQ

Should I use the web component or the React package?

Use the @stoplight/elements web component for plain HTML, server-rendered pages, or any non-React stack. Use the React package when you need to mount the reference inside an existing React or Next.js application and share routing or theming with the host app.

Does Stoplight Elements support OpenAPI 3.1?

Yes. Recent @stoplight/elements releases parse OpenAPI 2.0, 3.0, and 3.1 documents. Lint the spec with @redocly/cli or Spectral first so unsupported or malformed constructs fail in CI rather than rendering blank in the browser.

Why does the hash router break when I deploy to a subpath?

The hash router stores state after the # and works on any host without server config, but it conflicts with anchor-based deep links. Switch to router="memory" for embedded panes or router="history" with a configured basePath when serving from a subdirectory.

How do I hide the Try It console for a public, read-only reference?

Set the hideTryIt attribute on the elements-api element. This removes the interactive request panel while keeping schemas, examples, and descriptions, which is useful for unauthenticated public docs or when the API has no CORS-enabled sandbox.

One more property of the embedded model is worth planning for: the component and the host application have separate ideas about the page. The host owns the document title, the meta description, the canonical URL and anything a crawler reads, and none of those update when a reader navigates within the reference. For an internal console that is irrelevant. For a public reference it means every operation page reports the same title and description to search engines, which is worth fixing by having the host listen for the component’s navigation events and update the document metadata to match — a small amount of glue that converts a single indexed page into a properly indexed reference.

Treated that way, an embedded reference is a low-commitment addition to a site you already run rather than a new system to operate, which is exactly the situation it suits best.

That glue is a few lines and it is the difference between an embedded reference and a properly indexed one.

Everything else about the embed is configuration you can change on any afternoon without consequence.