Embedding Stoplight Elements in a React App
This guide is part of Stoplight Elements within Developer Portal Frameworks & UI Setup. It walks through embedding the @stoplight/elements <API> component inside a React + Vite application so you can ship an interactive API reference as a route in your own app rather than a standalone page. This scenario arises when you already own a React portal shell — navigation, auth, theming — and want the reference docs to live inside it instead of an iframe or a separate Redoc/Scalar deployment.
Problem & Context
A common failure mode is dropping the <API> component into a React tree and getting one of two broken states. First, an unstyled wall of text: the component renders the operation list and schemas but with no layout, because the stylesheet was never imported. Elements ships its CSS separately from the JS bundle, so importing the React component alone gets you markup with no design tokens.
Second, blank screens or 404s on refresh. Elements is internally routed — clicking an operation changes the URL. With the default history router on a static host, refreshing /docs/operations/get-users asks the server for a file that does not exist, returning a 404. The fix is choosing the right router prop for your hosting and embedding model, which this guide makes explicit.
Step-by-Step Solution
1. Scaffold a Vite React app
npm create vite@latest api-portal -- --template react
cd api-portal
npm install
Expected output ends with:
Scaffolding project in ./api-portal...
Done. Now run:
npm run dev
2. Install Stoplight Elements
Pin the major version — Elements 7.x and 8.x differ in CSS token names, so unpinned installs cause silent theme drift:
npm install @stoplight/[email protected]
Verify the resolved version:
npm ls @stoplight/elements
# [email protected]
# └── @stoplight/[email protected]
3. Import the stylesheet at the app entry point
Import the CSS exactly once, at the top of src/main.jsx, so it loads before any <API> mounts:
// src/main.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import '@stoplight/elements/styles.min.css' // required — ships separately from the JS
import App from './App.jsx'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
If you forget this line, the component renders but appears as raw, unstyled HTML.
4. Render the <API> component
Use apiDescriptionUrl to point at a spec, and set router="hash" so deep links survive a page refresh on a static host:
// src/App.jsx
import { API } from '@stoplight/elements'
export default function App() {
return (
<div style={{ height: '100vh' }}>
<API
apiDescriptionUrl="/openapi.yaml" // fetched at runtime from public/
router="hash" // URLs become /#/operations/... — refresh-safe
layout="sidebar" // sidebar | stacked
/>
</div>
)
}
Give the wrapper an explicit height. Elements fills its container, and a zero-height parent collapses the whole reference to nothing.
5. Serve the spec and run the dev server
Place your OpenAPI document in public/ so Vite serves it at the site root:
cp ../specs/openapi.yaml public/openapi.yaml
npm run dev
Expected output:
VITE v5.4.0 ready in 412 ms
➜ Local: http://localhost:5173/
Open http://localhost:5173/ and you should see the sidebar reference. Clicking an operation changes the URL to http://localhost:5173/#/operations/getUsers, and refreshing keeps you on that operation.
Complete Working Example
A single self-contained App.jsx that handles the load state, lets you switch routers via a prop, and embeds Elements as a route-friendly component:
// src/App.jsx — complete, copy-paste ready
import { useState } from 'react'
import { API } from '@stoplight/elements'
// router: "hash" for a standalone docs page on a static host (refresh-safe),
// "memory" when this component lives inside another routed view and
// must NOT touch the browser URL.
const ROUTER_MODE = 'hash'
export default function App() {
const [specUrl] = useState('/openapi.yaml')
return (
<div className="app-shell" style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<header style={{ padding: '0 16px', borderBottom: '1px solid #d8dff2' }}>
<h1 style={{ fontSize: 18, color: '#16295d' }}>Acme API Reference</h1>
</header>
<main style={{ flex: 1, minHeight: 0 /* lets Elements scroll inside flex */ }}>
<API
apiDescriptionUrl={specUrl}
router={ROUTER_MODE}
layout="sidebar"
hideExport={false} // show the "Export" / download-spec control
tryItCredentialsPolicy="same-origin" // omit cookies on cross-origin Try It calls
/>
</main>
</div>
)
}
To embed inside an existing React Router app instead, mount this on a wildcard route and switch ROUTER_MODE to 'memory' so Elements manages its own internal navigation without fighting your top-level router:
// In your route config (react-router-dom v6)
<Route path="/docs/*" element={<App />} />
Gotchas & Edge Cases
Collapsed-to-nothing layout. Elements has no intrinsic height. If the reference is invisible but present in the DOM, the parent container has zero height. Set height: 100vh (or flex: 1 with minHeight: 0) on the wrapper.
Nested router URL collisions. Using router="history" inside an app that already runs React Router means two routers compete for the same URL, producing flicker and broken back-button behaviour. Inside a routed app, use router="memory"; for a dedicated docs subdomain or static host, use router="hash".
Try-It CORS failures. The interactive console fires real requests from the browser. If the API server lacks Access-Control-Allow-Origin for your docs origin, calls fail with a CORS error in the console even though the spec renders fine. Add the docs origin to the API’s allowed origins, or set a corsProxy on the <API> component for non-production environments.
Mount the component once and let it own its subtree. A web component inside a React tree is a foreign element as far as React is concerned: React manages the element that hosts it, and the component manages everything inside. Re-rendering the host on unrelated state changes forces the component to tear down and rebuild the reference, which is visibly slow on a large specification and loses the reader’s scroll position. Memoise the wrapper and keep the props it depends on stable.
Pass the document rather than a URL when anything is non-trivial. Handing the component a URL is convenient and gives up control of the request — which matters as soon as the specification is gated, versioned, or served from somewhere that needs a header. Fetching it in your own effect and passing the parsed document lets you attach credentials, detect an expired session, and render a real error message instead of an empty region.
Decide routing before anyone links into the reference. Hash routing is inert and never conflicts with the host router, which makes it the safe default inside an existing application. History routing produces cleaner URLs and requires the host router to yield the reference’s path prefix. Both work; switching later changes every deep link into an operation page, and those links live in tickets and chat threads you cannot edit.
FAQ
Why is my Stoplight Elements component unstyled?
You imported the component but not its stylesheet. Add import '@stoplight/elements/styles.min.css' once at your app entry point so the bundled tokens and layout load.
Should I use hash or memory routing for Stoplight Elements?
Use router="hash" when Elements owns its own URL space and you want deep links to operations. Use router="memory" when you embed Elements inside another routed view and do not want it to touch the browser URL.
Can I pass a raw OpenAPI object instead of a URL?
Yes. Use the apiDescriptionDocument prop with a parsed object or JSON string instead of apiDescriptionUrl. This avoids a network round trip when the spec is already bundled.
Related
- Stoplight Elements — parent guide and configuration reference
- Scalar & Modern Docs Integration — an alternative embeddable reference renderer
- Swagger UI Customization — for teams comparing embed options
- Developer Portal Frameworks & UI Setup — the parent overview
One last integration detail worth handling early: the host application owns the document title, the meta description and the canonical URL, and none of them update as a reader navigates within the reference. For an internal console that does not matter. For anything public it means every operation page reports identical metadata, so listening for the component’s navigation events and updating the document head is a small piece of glue that turns a single indexed page into a properly indexed reference.
Beyond that, keep the integration surface deliberately small. The component’s value is that it renders a reference well with almost no configuration, and every additional hook into the host application is something to re-verify on each upgrade. A memoised wrapper, a fetch you control, and a settled routing mode are enough for almost every embed, and stopping there is what keeps the component a dependency rather than a fork.
If the integration starts growing past that — several hooks, a wrapper that reimplements navigation, styling that reaches into the component’s internals — that is usually a sign the requirement has outgrown an embedded component and wants a documentation framework instead. Recognising it early is cheaper than discovering it after a year of accumulated glue.
The signal to watch for is glue code growing faster than the reference itself, because that ratio only moves in one direction once it starts.
Keeping the embed small also keeps the upgrade path open: a component with three integration points can be swapped for a different renderer in an afternoon, while one wrapped in a layer of custom navigation and styling effectively cannot be swapped at all.