Documenting APIs with Swagger
A REST API is only as usable as its documentation. OpenAPI (formerly Swagger Specification) is the industry-standard, language-neutral format for describing HTTP APIs — endpoints, parameters, request bodies, responses, and auth — in a single machine-readable document. From that spec you get an interactive “try it out” UI, client SDK generators, and contract tests for free. This page shows how to write an OpenAPI spec, serve it with swagger-ui-express, generate it from JSDoc comments, and keep the docs honest as your code evolves.
Installing the tooling
Two small packages cover most needs: swagger-ui-express renders the interactive docs page, and swagger-jsdoc builds the spec from annotations in your source so it lives next to the code it describes.
npm install swagger-ui-express swagger-jsdoc
Writing an OpenAPI document
An OpenAPI document is just JSON (or YAML) with a fixed shape. The top level declares the openapi version, an info block, and a paths map keyed by URL. Reusable schemas go under components and are referenced with $ref. Here is a minimal but complete spec for a users resource.
// docs/openapi.js
module.exports = {
openapi: "3.0.3",
info: {
title: "Users API",
version: "1.0.0",
description: "Manage application users.",
},
servers: [{ url: "http://localhost:3000" }],
paths: {
"/users/{id}": {
get: {
summary: "Get a user by ID",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "integer" },
},
],
responses: {
200: {
description: "The requested user",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/User" },
},
},
},
404: { description: "User not found" },
},
},
},
},
components: {
schemas: {
User: {
type: "object",
properties: {
id: { type: "integer", example: 42 },
firstName: { type: "string", example: "Ada" },
lastName: { type: "string", example: "Lovelace" },
},
},
},
},
};
Serving the docs with swagger-ui-express
swagger-ui-express exposes the spec as an interactive page. Mount its middleware on a route — /docs is conventional — and pass the spec object. Visitors get a browsable UI with a working “Try it out” button that fires real requests against your server.
const express = require("express");
const swaggerUi = require("swagger-ui-express");
const openapiSpec = require("./docs/openapi");
const app = express();
app.use(express.json());
app.use("/docs", swaggerUi.serve, swaggerUi.setup(openapiSpec));
// Expose the raw spec so other tools can consume it
app.get("/openapi.json", (req, res) => res.json(openapiSpec));
app.listen(3000, () => console.log("Docs at http://localhost:3000/docs"));
Output: what the two routes return.
GET /docs -> Interactive Swagger UI (HTML)
GET /openapi.json -> { "openapi": "3.0.3", "info": { ... }, "paths": { ... } }
In Express 5,
swagger-ui-expressworks unchanged becauseapp.usemounting is stable. Just keep theserveandsetupmiddleware in that order —serveexposes the static assets,setuprenders the page.
Generating the spec from JSDoc
Maintaining a separate spec file is tedious and drifts from reality. swagger-jsdoc builds the paths for you by scanning JSDoc-style @openapi comments above your route handlers, so the documentation lives beside the code.
// docs/spec.js
const swaggerJsdoc = require("swagger-jsdoc");
module.exports = swaggerJsdoc({
definition: {
openapi: "3.0.3",
info: { title: "Users API", version: "1.0.0" },
},
apis: ["./routes/*.js"], // files to scan for annotations
});
Annotate each route with a YAML block inside a JSDoc comment. The YAML is standard OpenAPI path-item syntax.
// routes/users.js
const router = require("express").Router();
/**
* @openapi
* /users/{id}:
* get:
* summary: Get a user by ID
* parameters:
* - name: id
* in: path
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: The requested user
* 404:
* description: User not found
*/
router.get("/:id", async (req, res) => {
const user = await db.users.find(req.params.id);
if (!user) return res.status(404).json({ error: "User not found" });
res.json(user);
});
module.exports = router;
Feed the generated spec into the same swaggerUi.setup(spec) call and the UI updates automatically on the next restart.
Spec-first vs. code-first
The two approaches above represent the two dominant workflows. Choose based on whether the contract or the implementation leads.
| Approach | How docs are produced | Best when |
|---|---|---|
Code-first (swagger-jsdoc) | JSDoc annotations scanned from handlers | Docs should track an evolving codebase |
Spec-first (hand-written openapi.js) | Spec authored first, code implements it | Multiple teams agree on a contract up front |
Keeping docs in sync with code
Documentation that lies is worse than none. The reliable fix is to make the spec part of your tests. Validate the document against the OpenAPI schema, and use a request validator so a route that drifts from its spec fails CI rather than misleading clients.
const { validate } = require("@apidevtools/swagger-parser");
test("spec is a valid OpenAPI document", async () => {
await validate("./openapi.json"); // throws on malformed spec
});
For runtime enforcement, libraries like express-openapi-validator reject requests and responses that violate the spec, turning your documentation into an executable contract.
Best Practices
- Keep schemas in
componentsand reference them with$refso request and response shapes never drift apart. - Prefer code-first annotations (
swagger-jsdoc) so docs move with the handler they describe instead of rotting in a separate file. - Serve the raw spec at
/openapi.jsonso client generators, Postman, and contract tests can consume it programmatically. - Add concrete
examplevalues to schemas — the “Try it out” panel pre-fills them and makes the docs self-explanatory. - Validate the spec in CI and, where it matters, validate requests/responses at runtime so a drifting endpoint fails loudly.
- Restrict or protect
/docsin production if your API is private; exposing the full surface area is a reconnaissance gift to attackers.