Back to Articles
August 11, 202612 min readAmrit Sapkota

Swagger UI in Next.js: Your API Deserves a Homepage

Swagger UI in Next.js: Your API Deserves a Homepage

You built the API, it works, and then someone asks what the body for creating a todo looks like — so you paste a curl command into Slack. Two weeks later you rename a field and that curl command becomes a lie. Wiring up Swagger UI in Next.js fixes that: one spec file becomes the source of truth, and your API gets a live homepage with a working "Try it out" button. Everything below was tested on Next.js 16.3, React 19.2, and swagger-ui-react 5.32 — including the two shortcuts most tutorials skip.

Why your API deserves a homepage

Interactive documentation quietly removes a whole class of interruptions from your week:

  • Frontend developers stop asking you for payload shapes.
  • QA can hit endpoints without installing Postman or writing a script.
  • New teammates read the entire surface area in five minutes instead of grepping route handlers.

The mental model: OpenAPI is the data, Swagger UI is the reader

Understand this before you write any code and every later step becomes obvious. There are three moving parts, and people usually confuse the first two:

  • OpenAPI is a spec format — a JSON object describing your paths, methods, bodies, and responses. Just data; it renders nothing.
  • The endpoint is a URL serving that JSON so a browser can fetch it.
  • Swagger UI is a viewer — a React app that fetches the JSON and turns it into the green-and-blue page you have seen a hundred times.

Swagger UI does not read your code. It only reads the spec you hand it.

Nothing here magically scans your route handlers — you describe the API once, deliberately, and the UI reflects what you wrote. It also means the viewer is swappable, which brings us to the part most tutorials skip.

The two-minute versions first

The full swagger-ui-react setup below is six files. Before committing to that, know that two shorter paths render the same spec — I verified both end to end.

Scalar needs one route file and no client component at all:

// src/app/reference/route.ts
import { ApiReference } from "@scalar/nextjs-api-reference";

export const GET = ApiReference({ url: "/openapi.json" });

No "use client", no dynamic import, no CSS import, no TypeScript shim — it ships its own types and styles. Swagger UI from a CDN goes further and needs no npm package at all, just a route handler returning HTML:

// src/app/cdn-doc/route.ts
export async function GET() {
  return new Response(
    `<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
<div id="swagger"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>SwaggerUIBundle({ url: "/openapi.json", dom_id: "#swagger" });</script>`,
    { headers: { "content-type": "text/html" } },
  );
}

The cost difference is not subtle. Peer dependencies excluded, swagger-ui-react installs 191 packages and about 106 MB; @scalar/nextjs-api-reference installs 10 packages and about 6 MB; the CDN route installs nothing.

So why still reach for swagger-ui-react? Because it is a real React component: you can drive it from state, feed it a spec built at runtime, and hook requestInterceptor to inject headers. If you only want a page for a spec that lives at a URL, take a shortcut and skip to locking the docs down.

Step 1: Install the viewer

npm install swagger-ui-react

One package, bundling Swagger UI and a React wrapper around it. If npm throws an ERESOLVE peer dependency error mentioning React 19, you are on an older release. The peer range widened to react: ">=16.8.0 <20" in 5.28.0 — every version from 5.11 to 5.27 still declares <19 and will refuse to install. Upgrade with npm install swagger-ui-react@latest rather than reaching for --legacy-peer-deps.

Step 2: Write the OpenAPI spec

Create src/lib/openapi.ts. Export a function rather than a plain object — Step 3 explains why, and it is the difference between "Try it out" working in production and not.

// src/lib/openapi.ts
export function createOpenApiSpec(baseUrl: string) {
  return {
    openapi: "3.0.3",

    info: { title: "Next.js Todo API", version: "1.0.0" },

    // Where "Try it out" sends its requests
    servers: [{ url: baseUrl }],

    // Collapsible section headings
    tags: [{ name: "Todos", description: "Todo CRUD operations" }],

    paths: {
      "/api/todos": {
        post: {
          tags: ["Todos"],
          summary: "Create a todo",
          requestBody: {
            required: true,
            content: {
              "application/json": {
                schema: { $ref: "#/components/schemas/CreateTodoInput" },
              },
            },
          },
          responses: { "201": { description: "Todo created" } },
        },
      },

      // Curly braces mark a path parameter — NOT a template literal
      "/api/todos/{id}": {
        get: {
          tags: ["Todos"],
          summary: "Get a todo by id",
          parameters: [
            { name: "id", in: "path", required: true, schema: { type: "integer" } },
          ],
          responses: { "404": { description: "Not found" } },
        },
      },
    },

    // Reusable shapes, referenced above with $ref
    components: {
      schemas: {
        CreateTodoInput: {
          type: "object",
          required: ["title"],
          properties: {
            title: { type: "string", example: "Learn API" },
            completed: { type: "boolean", example: false },
          },
        },
      },
    },
  };
}

Use 3.0.3 unless you have a reason not to — it has the widest tool support. info becomes the page header, tags the collapsible group headings, paths holds one key per URL then one per HTTP method, and components.schemas lets you define a body shape once and $ref it anywhere.

Two details trip people up. {id} is OpenAPI syntax, not JavaScript — the path key is the literal string, and you declare the parameter separately with in: "path". And example values pre-fill the "Try it out" form, which makes good examples the highest-leverage thing you can write.

Step 3: Serve the spec at /openapi.json

Here is an App Router trick a lot of people miss: a route folder can be named openapi.json. The dot is just a character in a directory name, so src/app/openapi.json/route.ts serves a URL ending in .json.

// src/app/openapi.json/route.ts
import { createOpenApiSpec } from "@/lib/openapi";

function getBaseUrl(request: Request) {
  const host = request.headers.get("host");

  // Most proxies set x-forwarded-proto; http keeps localhost working.
  const protocol =
    request.headers.get("x-forwarded-proto") ??
    (host?.includes("localhost") ? "http" : "https");

  return `${protocol}://${host}`;
}

export async function GET(request: Request) {
  return Response.json(createOpenApiSpec(getBaseUrl(request)));
}

Why the function-plus-baseUrl dance? servers[0].url decides where the Execute button sends requests. Hardcode http://localhost:3000 and your production docs will fire every request at the reader's own machine. Deriving it from the request means one spec works on localhost, preview deploys, and production with zero configuration.

Check it with curl http://localhost:3000/openapi.json — JSON starting with {"openapi":"3.0.3",... means it works. HTML means your file is in the wrong place.

Step 4: Add the Swagger UI docs page

Create src/app/api-doc/page.tsx. On current versions this is genuinely all it takes:

// src/app/api-doc/page.tsx
"use client";

import SwaggerUI from "swagger-ui-react";

export default function ApiDocPage() {
  return <SwaggerUI url="/openapi.json" />;
}

If you have read other guides, that will look wrong — almost every one insists you must load the component through next/dynamic with ssr: false or hit ReferenceError: window is not defined. That was true once. On swagger-ui-react 5.32 with Next.js 16 it is not: the plain client component above compiles, prerenders as static content during next build, and renders with no error and no hydration warning. I tested exactly that before writing this.

Why is it safe? Even without ssr: false, the server HTML contains no Swagger markup — the component produces its output on the client either way. Skipping SSR was never buying rendering behaviour; it worked around a crash that no longer happens. The dynamic version still earns its place for one reason: a loading state while the sizeable bundle downloads, instead of an empty page.

// optional: adds a loading fallback
const SwaggerUI = dynamic<SwaggerUIProps>(
  () =>
    import("swagger-ui-react").then(
      (mod) => mod.default as ComponentType<SwaggerUIProps>,
    ),
  { ssr: false, loading: () => <div>Loading API docs…</div> },
);

"use client" is required either way, because Swagger UI manages expand/collapse state, form inputs, and fetch calls. Keep url relative so it resolves against whatever host the page is on. The cast exists only because the default export is not typed in a way next/dynamic accepts — Step 6 makes SwaggerUIProps exist at all.

You can also skip the fetch and pass the spec inline with <SwaggerUI spec={createOpenApiSpec("")} />, but you lose the shareable URL that Postman, Insomnia, and SDK generators import. Keep the endpoint.

Step 5: Load the stylesheet

This is the number one "why does my page look broken" bug. swagger-ui-react does not inject its own CSS — without this import you get a wall of unstyled blue links.

// src/app/layout.tsx
import "./globals.css";
import "swagger-ui-react/swagger-ui.css";   // ← this line

Why a layout and not the page? Next.js only allows global CSS imports — stylesheets that are not CSS Modules — from a layout. Putting the import in page.tsx throws a build error.

The tradeoff: this roughly 180 KB stylesheet now loads on every page. For an internal tool, ignore it. Otherwise create src/app/api-doc/layout.tsx that does nothing but import the CSS and return its children — nested layouts carry their own imports, so the cost stays scoped to the docs route.

Step 6: Make TypeScript happy

swagger-ui-react ships no type declarations at all — no types field, no bundled .d.ts. This is not a warning you can shrug off: next build fails outright with TS7016. There is a third-party @types/swagger-ui-react, but it lags behind, and a short shim you control declares exactly the props you use.

// src/types/swagger-ui-react.d.ts
declare module "swagger-ui-react" {
  import type { ComponentType } from "react";

  export interface SwaggerUIProps {
    url?: string;
    spec?: unknown;
    docExpansion?: "list" | "full" | "none";
    displayRequestDuration?: boolean;
    filter?: boolean | string;
    persistAuthorization?: boolean;
    withCredentials?: boolean;
    requestInterceptor?: (req: unknown) => unknown;
  }

  const SwaggerUI: ComponentType<SwaggerUIProps>;
  export default SwaggerUI;
}

Next.js picks up any .d.ts covered by the include in tsconfig.json**/*.ts by default — so no config change is needed. Need a prop that is not listed? Add the line, you own this file.

Run it and verify

Open http://localhost:3000/api-doc. You should see the title block from info, your Todos group, and colour-coded method chips. Now do the thing that makes this worth it: expand POST /api/todos, click Try it out, and hit Execute. Your spec's example values are already in the box, and the real response comes back from your running API.

Making "Try it out" work on protected endpoints

Once you add auth, you need to tell Swagger UI how to send credentials — that is what the Authorize button is for. Add a securitySchemes block to components, then declare which operations need it.

components: {
  securitySchemes: {
    bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
  },
  schemas: { /* ... */ },
},

// Applies to every operation; nest it inside one to scope it.
security: [{ bearerAuth: [] }],

An Authorize button now appears at the top of the page: paste a token once and every request carries Authorization: Bearer <token>. Other schemes work the same way — { type: "apiKey", in: "header", name: "X-API-Key" }, or in: "cookie" for a session cookie.

Cookie auth also needs withCredentials so the browser attaches the cookie, and persistAuthorization keeps your token across refreshes — handy in development, leave it off if the docs are public:

<SwaggerUI url="/openapi.json" withCredentials persistAuthorization />

Two more props matter once the page gets long: docExpansion="none" collapses everything and filter adds a search box. Past fifteen endpoints, they turn an endless scroll into something navigable.

Lock the docs down before you deploy

Your spec is a map of your entire API surface — a gift to your team and, depending on the product, to anyone scanning your domain. To gate the page, split it into a Server Component that checks the environment and a Client Component that renders the UI:

// src/app/api-doc/page.tsx — Server Component, no "use client"
import { notFound } from "next/navigation";
import SwaggerClient from "./swagger-client";

export default function ApiDocPage() {
  if (
    process.env.NODE_ENV === "production" &&
    process.env.ENABLE_API_DOCS !== "true"
  ) {
    notFound();
  }

  return <SwaggerClient />;
}

The client half is the file from Step 4, renamed to swagger-client.tsx. Apply the same check in your openapi.json route handler and return a 404 — hiding the page while leaving the spec endpoint open achieves nothing. Now ENABLE_API_DOCS=true gives your team the docs in preview, while production stays quiet by default.

Troubleshooting the usual suspects

  • Plain blue links, no styling — the stylesheet was never imported. Add import "swagger-ui-react/swagger-ui.css" to a layout, not a page.
  • A "Failed to load API definition" banner/openapi.json is 404ing or returning HTML. Check the folder is src/app/openapi.json/route.ts and exports GET.
  • TS7016: Could not find a declaration file — no bundled types. Add the .d.ts shim from Step 6, or the build fails.
  • npm ERR! ERESOLVE mentioning React 19 — a swagger-ui-react older than 5.28. Run npm i swagger-ui-react@latest.
  • ReferenceError: window is not defined — an older swagger-ui-react or Next.js. Upgrade first; if you are stuck, load it through dynamic(..., { ssr: false }).
  • "Try it out" 404s in productionservers[0].url is hardcoded to localhost. Derive baseUrl from the request as in Step 3.
  • "Try it out" returns 401 while you are logged in — cookies are not sent by default. Add withCredentials.

In a plain React SPA on Vite, none of the Next.js-specific pieces apply: drop the spec at public/openapi.json, import the component and CSS directly, point servers[0].url at your API's host, and expect to need CORS headers.

Keeping the spec from drifting

The honest caveat: hand-written specs drift. Rename a field and the docs quietly keep describing the old one. Three fixes, in order of effort:

  • Generate from your validators. If you already use Zod, zod-to-openapi derives the schema from the same objects your routes enforce, which makes drift impossible for request bodies.
  • Generate from JSDoc comments. next-swagger-doc scans annotated comments above your route handlers — docs next to code, at the cost of a lot of comment blocks.
  • Add a spec test. Assert in CI that every exported route handler appears in paths. Cheap, and it catches the most common drift: a whole endpoint nobody documented.

And the viewer stays swappable — the same /openapi.json powers Scalar, Redoc, and Stoplight Elements, which is exactly the point of keeping the spec separate from the reader.


Frequently Asked Questions

What is the simplest way to add API docs to a Next.js app?

A single route file using @scalar/nextjs-api-reference: export const GET = ApiReference({ url: "/openapi.json" }). No client component, no CSS import, no TypeScript shim, and roughly 10 packages instead of 191. Reach for swagger-ui-react only when you need Swagger UI as a React component you can drive from state.

Do I still need ssr: false with swagger-ui-react?

Not on current versions. On swagger-ui-react 5.32 with Next.js 16 and React 19, a plain "use client" page that imports the component directly builds, prerenders, and renders with no error. The next/dynamic wrapper is optional now; its remaining value is the loading fallback. On older versions the crash was real, so upgrade before you debug.

Why does "Try it out" fail in production but work locally?

Almost always because servers[0].url is hardcoded to http://localhost:3000, so the browser fires requests at the reader's own machine. Derive baseUrl from the request's host and x-forwarded-proto headers instead. If requests return 401, you need withCredentials or a token in the Authorize dialog.

Putting it into practice

Write the spec once, serve it at a stable URL, and pick the lightest viewer that does what you need — one route file for a page, the full component for control. Gate both routes before you deploy. Either way, every conversation that used to start with "hey, what's the payload for…" now ends with a link. For more App Router patterns, the Next.js category collects deeper dives on routing, performance, and server components.

Thanks for reading!