Skip to main content
Back to Blog
Tutorials
3 min read
December 8, 2024

How to Build an OpenAPI Documentation Site with Next.js

Create auto-generated, interactive API documentation from OpenAPI specs using Next.js and Scalar.

Ryel Banfield

Founder & Lead Developer

Interactive API docs help developers adopt your API faster. Here is how to generate them automatically from an OpenAPI spec.

Define Your OpenAPI Spec

// lib/openapi.ts
import { OpenAPIV3 } from "openapi-types";

export const openApiDocument: OpenAPIV3.Document = {
  openapi: "3.0.3",
  info: {
    title: "My API",
    version: "1.0.0",
    description: "REST API for managing resources",
  },
  servers: [
    {
      url: "https://api.example.com/v1",
      description: "Production",
    },
    {
      url: "http://localhost:3000/api/v1",
      description: "Local development",
    },
  ],
  paths: {
    "/users": {
      get: {
        summary: "List users",
        operationId: "listUsers",
        tags: ["Users"],
        parameters: [
          {
            name: "page",
            in: "query",
            schema: { type: "integer", default: 1 },
          },
          {
            name: "limit",
            in: "query",
            schema: { type: "integer", default: 20, maximum: 100 },
          },
        ],
        responses: {
          "200": {
            description: "Paginated list of users",
            content: {
              "application/json": {
                schema: {
                  type: "object",
                  properties: {
                    data: {
                      type: "array",
                      items: { $ref: "#/components/schemas/User" },
                    },
                    pagination: { $ref: "#/components/schemas/Pagination" },
                  },
                },
              },
            },
          },
        },
      },
      post: {
        summary: "Create a user",
        operationId: "createUser",
        tags: ["Users"],
        requestBody: {
          required: true,
          content: {
            "application/json": {
              schema: { $ref: "#/components/schemas/CreateUser" },
            },
          },
        },
        responses: {
          "201": {
            description: "User created",
            content: {
              "application/json": {
                schema: { $ref: "#/components/schemas/User" },
              },
            },
          },
          "422": {
            description: "Validation error",
            content: {
              "application/json": {
                schema: { $ref: "#/components/schemas/Error" },
              },
            },
          },
        },
      },
    },
  },
  components: {
    schemas: {
      User: {
        type: "object",
        properties: {
          id: { type: "string", format: "uuid" },
          name: { type: "string" },
          email: { type: "string", format: "email" },
          createdAt: { type: "string", format: "date-time" },
        },
        required: ["id", "name", "email", "createdAt"],
      },
      CreateUser: {
        type: "object",
        properties: {
          name: { type: "string", minLength: 1, maxLength: 100 },
          email: { type: "string", format: "email" },
        },
        required: ["name", "email"],
      },
      Pagination: {
        type: "object",
        properties: {
          page: { type: "integer" },
          limit: { type: "integer" },
          total: { type: "integer" },
          totalPages: { type: "integer" },
        },
      },
      Error: {
        type: "object",
        properties: {
          message: { type: "string" },
          code: { type: "string" },
          details: {
            type: "array",
            items: {
              type: "object",
              properties: {
                field: { type: "string" },
                message: { type: "string" },
              },
            },
          },
        },
      },
    },
    securitySchemes: {
      bearerAuth: {
        type: "http",
        scheme: "bearer",
        bearerFormat: "JWT",
      },
    },
  },
  security: [{ bearerAuth: [] }],
};

Serve the OpenAPI JSON

// app/api/openapi/route.ts
import { NextResponse } from "next/server";
import { openApiDocument } from "@/lib/openapi";

export async function GET() {
  return NextResponse.json(openApiDocument);
}

Option 1: Scalar API Docs

Scalar provides a modern, beautiful API reference UI.

pnpm add @scalar/nextjs-api-reference
// app/docs/api/page.tsx
import { ApiReference } from "@scalar/nextjs-api-reference";

export default function ApiDocsPage() {
  return (
    <ApiReference
      configuration={{
        url: "/api/openapi",
        theme: "kepler",
        layout: "modern",
        hideModels: false,
        searchHotKey: "k",
      }}
    />
  );
}

Option 2: Custom Documentation UI

Build your own docs from the OpenAPI spec.

// app/docs/api/page.tsx
import { openApiDocument } from "@/lib/openapi";
import type { OpenAPIV3 } from "openapi-types";

function MethodBadge({ method }: { method: string }) {
  const colors: Record<string, string> = {
    get: "bg-blue-100 text-blue-800",
    post: "bg-green-100 text-green-800",
    put: "bg-yellow-100 text-yellow-800",
    patch: "bg-orange-100 text-orange-800",
    delete: "bg-red-100 text-red-800",
  };

  return (
    <span
      className={`rounded px-2 py-1 text-xs font-bold uppercase ${colors[method] ?? "bg-gray-100 text-gray-800"}`}
    >
      {method}
    </span>
  );
}

function EndpointCard({
  path,
  method,
  operation,
}: {
  path: string;
  method: string;
  operation: OpenAPIV3.OperationObject;
}) {
  return (
    <div className="border rounded-lg p-4">
      <div className="flex items-center gap-3 mb-2">
        <MethodBadge method={method} />
        <code className="text-sm font-mono">{path}</code>
      </div>
      <p className="text-sm text-muted-foreground">{operation.summary}</p>
      {operation.parameters && (
        <div className="mt-3">
          <h4 className="text-xs font-semibold uppercase text-muted-foreground mb-1">
            Parameters
          </h4>
          <div className="space-y-1">
            {(operation.parameters as OpenAPIV3.ParameterObject[]).map((param) => (
              <div key={param.name} className="flex items-center gap-2 text-sm">
                <code className="text-xs bg-muted px-1 rounded">{param.name}</code>
                <span className="text-xs text-muted-foreground">{param.in}</span>
                {param.required && (
                  <span className="text-xs text-red-500">required</span>
                )}
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

export default function ApiDocsPage() {
  const { paths, info } = openApiDocument;

  const groupedByTag: Record<
    string,
    { path: string; method: string; operation: OpenAPIV3.OperationObject }[]
  > = {};

  if (paths) {
    for (const [path, methods] of Object.entries(paths)) {
      for (const [method, operation] of Object.entries(methods as Record<string, OpenAPIV3.OperationObject>)) {
        const tag = operation.tags?.[0] ?? "Other";
        if (!groupedByTag[tag]) groupedByTag[tag] = [];
        groupedByTag[tag].push({ path, method, operation });
      }
    }
  }

  return (
    <div className="max-w-4xl mx-auto py-12 px-4">
      <h1 className="text-3xl font-bold mb-2">{info.title}</h1>
      <p className="text-muted-foreground mb-8">{info.description}</p>

      {Object.entries(groupedByTag).map(([tag, endpoints]) => (
        <section key={tag} className="mb-10">
          <h2 className="text-xl font-semibold mb-4">{tag}</h2>
          <div className="space-y-4">
            {endpoints.map(({ path, method, operation }) => (
              <EndpointCard
                key={`${method}-${path}`}
                path={path}
                method={method}
                operation={operation}
              />
            ))}
          </div>
        </section>
      ))}
    </div>
  );
}

Validate Routes Against the Spec

// lib/openapi-validator.ts
import { openApiDocument } from "./openapi";
import { z } from "zod";

export function getRequestSchema(operationId: string) {
  const paths = openApiDocument.paths ?? {};

  for (const methods of Object.values(paths)) {
    for (const operation of Object.values(methods as Record<string, any>)) {
      if (operation.operationId === operationId) {
        return operation.requestBody?.content?.["application/json"]?.schema;
      }
    }
  }
  return null;
}

// Usage in API routes
// app/api/v1/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";

const CreateUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
});

export async function POST(request: NextRequest) {
  const body = await request.json();
  const parsed = CreateUserSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json(
      {
        message: "Validation error",
        code: "VALIDATION_ERROR",
        details: parsed.error.issues.map((issue) => ({
          field: issue.path.join("."),
          message: issue.message,
        })),
      },
      { status: 422 }
    );
  }

  // Create user...
  return NextResponse.json({ id: "...", ...parsed.data }, { status: 201 });
}

Need API Documentation for Your Product?

We build developer-friendly API documentation integrated into your marketing site. Contact us to create docs that drive adoption.

OpenAPISwaggerAPI documentationNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles