Skip to main content

Guide

OpenAPI for Node.js 2026: swagger vs hono vs fastify

Compare swagger-ui-express, @hono/zod-openapi, and @fastify/swagger for generating and serving OpenAPI specs in Node.js. Schema-first vs code-first, Zod.

·PkgPulse Team·
0

TL;DR

swagger-ui-express is the manual approach — write your OpenAPI spec by hand or generate it with swagger-jsdoc, then serve Swagger UI. @hono/zod-openapi is the modern code-first approach — define routes with Zod schemas and get a type-safe OpenAPI spec automatically, ideal for Hono APIs. @fastify/swagger + @fastify/swagger-ui is the Fastify ecosystem's built-in solution — schema validation and OpenAPI generation from the same JSON Schema definitions. Choose based on your framework: manual/any framework → swagger-ui-express, Hono → @hono/zod-openapi, Fastify → @fastify/swagger.

Key Takeaways

  • swagger-ui-express: ~2M weekly downloads — serve Swagger UI for any spec, Express/any framework
  • @hono/zod-openapi: ~300K weekly downloads — Zod-first OpenAPI + type-safe routing for Hono
  • @fastify/swagger: ~1M weekly downloads — JSON Schema to OpenAPI for Fastify
  • Code-first approach (Zod/JSON Schema → spec) prevents spec/code divergence
  • Manual YAML specs go stale — auto-generated specs stay in sync with your code
  • All three serve Swagger UI for interactive API documentation

PackageWeekly DownloadsApproachType SafetyFramework
swagger-ui-express~2MManual / jsdocExpress/any
@hono/zod-openapi~300KZod → OpenAPI✅ ExcellentHono
@fastify/swagger~1MJSON Schema → OpenAPIFastify

swagger-ui-express

swagger-ui-express serves Swagger UI for any OpenAPI spec — write the spec manually or generate it with swagger-jsdoc.

Manual spec with swagger-ui-express

import express from "express"
import swaggerUi from "swagger-ui-express"

const app = express()

// Define OpenAPI spec manually (or import from YAML/JSON file):
const swaggerDocument = {
  openapi: "3.0.0",
  info: {
    title: "PkgPulse API",
    version: "1.0.0",
    description: "API for npm package health analytics",
  },
  servers: [
    { url: "https://api.pkgpulse.com", description: "Production" },
    { url: "http://localhost:3000", description: "Development" },
  ],
  paths: {
    "/packages/{name}": {
      get: {
        summary: "Get package data",
        tags: ["Packages"],
        parameters: [
          {
            in: "path",
            name: "name",
            required: true,
            schema: { type: "string" },
            example: "react",
          },
        ],
        responses: {
          "200": {
            description: "Package data",
            content: {
              "application/json": {
                schema: {
                  type: "object",
                  properties: {
                    name: { type: "string" },
                    version: { type: "string" },
                    weeklyDownloads: { type: "number" },
                    healthScore: { type: "number", minimum: 0, maximum: 100 },
                  },
                },
              },
            },
          },
          "404": { description: "Package not found" },
        },
      },
    },
  },
}

// Serve Swagger UI at /api-docs:
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument))

// Serve raw JSON spec:
app.get("/api-docs.json", (req, res) => res.json(swaggerDocument))

Auto-generated with swagger-jsdoc

import swaggerJsdoc from "swagger-jsdoc"
import swaggerUi from "swagger-ui-express"

const options = {
  definition: {
    openapi: "3.0.0",
    info: { title: "PkgPulse API", version: "1.0.0" },
  },
  // Parse JSDoc comments from route files:
  apis: ["./src/routes/**/*.ts"],
}

const spec = swaggerJsdoc(options)
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(spec))

// In route files, add JSDoc comments:
/**
 * @openapi
 * /packages/{name}:
 *   get:
 *     summary: Get package health data
 *     tags: [Packages]
 *     parameters:
 *       - in: path
 *         name: name
 *         schema:
 *           type: string
 *         required: true
 *     responses:
 *       200:
 *         description: Package found
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/Package'
 */
app.get("/packages/:name", packageHandler)

@hono/zod-openapi

@hono/zod-openapi — define routes with Zod schemas, get OpenAPI spec and type-safe handlers automatically.

Complete Hono API with auto-generated OpenAPI

import { Hono } from "hono"
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"
import { swaggerUI } from "@hono/swagger-ui"

const app = new OpenAPIHono()

// Define schemas with Zod (one source of truth):
const PackageSchema = z.object({
  name: z.string().openapi({ example: "react" }),
  version: z.string().openapi({ example: "18.3.1" }),
  weeklyDownloads: z.number().int().nonnegative().openapi({ example: 25000000 }),
  healthScore: z.number().min(0).max(100).openapi({ example: 92 }),
  description: z.string().optional(),
}).openapi("Package")

const PackageNotFoundSchema = z.object({
  message: z.string(),
  code: z.string(),
}).openapi("PackageNotFound")

// Define route (fully typed, generates OpenAPI spec):
const getPackageRoute = createRoute({
  method: "get",
  path: "/packages/{name}",
  tags: ["Packages"],
  summary: "Get package health data",
  request: {
    params: z.object({
      name: z.string().min(1).openapi({ example: "react" }),
    }),
    query: z.object({
      includeHistory: z.boolean().optional().openapi({ example: true }),
    }),
  },
  responses: {
    200: {
      content: { "application/json": { schema: PackageSchema } },
      description: "Package data",
    },
    404: {
      content: { "application/json": { schema: PackageNotFoundSchema } },
      description: "Package not found",
    },
  },
})

// Handler is fully typed from the route definition:
app.openapi(getPackageRoute, async (c) => {
  const { name } = c.req.valid("param")     // Typed as { name: string }
  const { includeHistory } = c.req.valid("query")  // Typed as { includeHistory?: boolean }

  const pkg = await fetchPackage(name)
  if (!pkg) {
    return c.json({ message: "Package not found", code: "NOT_FOUND" }, 404)
  }

  return c.json(pkg, 200)  // Must match PackageSchema
})

// POST with body validation:
const createAlertRoute = createRoute({
  method: "post",
  path: "/alerts",
  tags: ["Alerts"],
  request: {
    body: {
      content: {
        "application/json": {
          schema: z.object({
            packageName: z.string().min(1),
            threshold: z.number().min(1).max(100),
            email: z.string().email(),
          }),
        },
      },
    },
  },
  responses: {
    201: {
      content: { "application/json": { schema: z.object({ id: z.string() }) } },
      description: "Alert created",
    },
  },
})

app.openapi(createAlertRoute, async (c) => {
  const body = c.req.valid("json")  // Typed as { packageName: string; threshold: number; email: string }
  const alert = await createAlert(body)
  return c.json({ id: alert.id }, 201)
})

// Auto-generate and serve OpenAPI spec:
app.doc("/openapi.json", {
  openapi: "3.0.0",
  info: { title: "PkgPulse API", version: "1.0.0" },
})

// Serve Swagger UI:
app.get("/docs", swaggerUI({ url: "/openapi.json" }))

export default app

@fastify/swagger

@fastify/swagger + @fastify/swagger-ui — Fastify's native OpenAPI integration.

Fastify with OpenAPI spec generation

import Fastify from "fastify"
import fastifySwagger from "@fastify/swagger"
import fastifySwaggerUi from "@fastify/swagger-ui"

const app = Fastify()

// Register swagger plugin:
await app.register(fastifySwagger, {
  openapi: {
    info: {
      title: "PkgPulse API",
      version: "1.0.0",
      description: "npm package health analytics API",
    },
    servers: [{ url: "https://api.pkgpulse.com" }],
    components: {
      securitySchemes: {
        bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
      },
    },
  },
})

// Register Swagger UI:
await app.register(fastifySwaggerUi, {
  routePrefix: "/docs",
  uiConfig: { deepLinking: true },
})

// Route schemas become OpenAPI spec automatically:
app.get(
  "/packages/:name",
  {
    schema: {
      description: "Get package health data",
      tags: ["Packages"],
      params: {
        type: "object",
        properties: {
          name: { type: "string", description: "npm package name", example: "react" },
        },
        required: ["name"],
      },
      response: {
        200: {
          type: "object",
          properties: {
            name: { type: "string" },
            version: { type: "string" },
            weeklyDownloads: { type: "number" },
            healthScore: { type: "number", minimum: 0, maximum: 100 },
          },
        },
        404: {
          type: "object",
          properties: {
            message: { type: "string" },
          },
        },
      },
    },
  },
  async (request, reply) => {
    const { name } = request.params as { name: string }
    const pkg = await fetchPackage(name)
    if (!pkg) {
      return reply.status(404).send({ message: "Package not found" })
    }
    return pkg
  }
)

// With TypeBox for better TypeScript integration:
import { Type } from "@sinclair/typebox"

app.post(
  "/alerts",
  {
    schema: {
      tags: ["Alerts"],
      body: Type.Object({
        packageName: Type.String({ minLength: 1 }),
        threshold: Type.Number({ minimum: 1, maximum: 100 }),
        email: Type.String({ format: "email" }),
      }),
      response: {
        201: Type.Object({ id: Type.String() }),
      },
    },
  },
  async (request, reply) => {
    const body = request.body  // TypeBox provides TypeScript types
    const alert = await createAlert(body)
    return reply.status(201).send({ id: alert.id })
  }
)

await app.ready()
// Spec available at: /docs/json
// Swagger UI at: /docs

Feature Comparison

Featureswagger-ui-express@hono/zod-openapi@fastify/swagger
FrameworkAny (Express focus)HonoFastify
Schema languageManual YAML/JSONZodJSON Schema / TypeBox
Type-safe handlers✅ Excellent✅ via TypeBox
Auto-spec generation❌ (jsdoc only)
Request validation✅ Built-in✅ Built-in
Swagger UI included✅ @hono/swagger-ui✅ @fastify/swagger-ui
Spec/code sync❌ Manual✅ Always in sync✅ Always in sync
Edge runtime

When to Use Each

Choose swagger-ui-express if:

  • Using Express with an existing OpenAPI spec
  • You want to serve Swagger UI for a spec written elsewhere
  • Framework-agnostic — works with any Node.js HTTP server
  • You need to serve a spec from a file without framework coupling

Choose @hono/zod-openapi if:

  • Building with Hono (edge-compatible framework)
  • You want Zod schemas as the single source of truth for validation + docs
  • Type-safe request/response handlers are critical
  • Edge runtime (Cloudflare Workers, Vercel Edge) deployment

Choose @fastify/swagger if:

  • Building with Fastify (the recommended Node.js API framework for performance)
  • Your team prefers JSON Schema / TypeBox over Zod
  • You want Fastify's built-in validation to also generate the spec
  • High-performance APIs where Fastify's speed advantage matters

The Spec Drift Problem and Code-First Solutions

Manual OpenAPI specs drift from implementation — this is the central argument for code-first approaches like @hono/zod-openapi and @fastify/swagger. When a developer adds a new query parameter to an endpoint, they update the route handler but forget to update the YAML spec. API consumers use the spec to generate clients and write integration tests, so stale specs directly cause integration failures. The code-first approach makes drift structurally impossible: the spec is derived from the same schema objects that validate requests and type handlers. This is not purely theoretical — teams that maintain large manual OpenAPI specs consistently report spec-code divergence as a real maintenance burden. For new projects in 2026, starting with a code-first approach is the clear recommendation.

TypeScript Client Generation from OpenAPI

One of the most valuable downstream uses of a well-maintained OpenAPI spec is generating TypeScript clients. Tools like openapi-typescript and orval consume OpenAPI JSON and produce fully typed client code — function signatures with correct parameter types, response types, and error types. With @hono/zod-openapi, the spec is always current, so running openapi-typescript generate as part of your build pipeline gives consumers a fresh, accurate client on every deployment. With swagger-ui-express and a manual spec, this workflow requires discipline to maintain spec accuracy. For internal API clients where you control both the server and client codebases, the generated client approach eliminates a whole class of runtime errors from API contract mismatches. Pair this with tRPC or similar if your client and server share a monorepo and you want even tighter type sharing without OpenAPI as intermediary.

Zod vs JSON Schema for API Validation

The choice between Zod (for @hono/zod-openapi) and JSON Schema (for @fastify/swagger with TypeBox) reflects a broader TypeScript ecosystem preference. Zod's API is more ergonomic for most TypeScript developers — method chaining like .string().email().max(255) reads naturally and integrates with React Hook Form, tRPC, and other ecosystem tools. TypeBox generates JSON Schema directly from TypeScript-like type definitions and has a performance advantage since Fastify can compile JSON Schema validators to optimized functions — Fastify's JSON Schema validation is one reason it benchmarks faster than Express or Hono. For pure API validation performance at high request volumes, TypeBox and Fastify win. For developer experience and ecosystem breadth, Zod and Hono or Express win. Both approaches beat maintaining separate schema definitions and Zod/class-validator runtime validation alongside OpenAPI YAML.

Security Considerations for API Documentation

Exposing Swagger UI in production requires security awareness. The /api-docs endpoint should not reveal internal implementation details to unauthorized users in production APIs. Options include: disabling Swagger UI in production (NODE_ENV !== "development" guard), requiring authentication before accessing the docs route, or hosting docs on a separate service with stricter access controls. OAuth2 callback URLs in the OpenAPI spec security definitions should use production URLs that do not include internal hostnames. The spec's servers array should list only appropriate environments — including staging or internal URLs in a public spec exposes internal infrastructure topology. For internal APIs in corporate environments, Swagger UI with LDAP or OAuth authentication is standard; for public APIs, unauthenticated docs access is expected but sensitive endpoint details should be omitted from the public spec.

Edge Deployment and Runtime Compatibility

The edge runtime story differs sharply between these three approaches. @hono/zod-openapi is designed specifically for edge runtimes — Hono itself runs on Cloudflare Workers, Vercel Edge Functions, and Deno Deploy, and the OpenAPI middleware follows. This makes it the only option among the three for APIs deployed to edge compute. @fastify/swagger requires Node.js since Fastify depends on Node.js APIs for its plugin system and HTTP server. swagger-ui-express requires an Express server, also Node.js only. For teams building on Cloudflare Workers where latency to end users and global distribution matter, @hono/zod-openapi is the only viable choice from this comparison. The trade-off is that Hono's ecosystem is smaller than Fastify's, and some Fastify plugins (database connectors, authentication, monitoring) have no direct Hono equivalent.

API Versioning and Documentation Maintenance

OpenAPI documentation becomes a living document in production APIs, requiring versioning strategies that match your API's evolution. For breaking changes, the convention is adding a version prefix to the path (/v1/packages becoming /v2/packages) and maintaining both OpenAPI spec versions simultaneously during the deprecation period. With code-first approaches like @hono/zod-openapi and @fastify/swagger, versioned routers are cleanly achievable: mount the v1 and v2 routers at their respective prefixes, each with its own schema definitions and OpenAPI spec generation. The combined spec can be served at /openapi.json with version selection or as separate versioned specs. For teams using the OpenAPI spec to generate client SDKs, publishing the spec as a versioned artifact in a package registry ensures client teams can pin to a stable spec version while you evolve the v2 API.

Mock Server Generation from OpenAPI Specs

One underappreciated benefit of maintaining an accurate OpenAPI spec is the ability to generate mock servers for frontend development and integration testing. Tools like msw (Mock Service Worker) and Prism can read an OpenAPI spec and automatically serve mock responses that match the spec's schemas. When the API is still in development or the backend team is unavailable, frontend developers can code against a mock server that reflects the actual API contract. With code-first approaches, the spec is always accurate — this means mock servers derived from the spec produce realistic test data shapes that match what the real API will return. swagger-ui-express with a manually written spec can also drive mock generation, but the spec must be kept current manually. For microservice integration testing in CI environments, spinning up a Prism mock server from your OpenAPI spec is faster and more reliable than starting the full downstream service stack.

Compare API and documentation packages on PkgPulse →

See also: Fastify vs Hono and Express vs Hono, better-sqlite3 vs libsql vs sql.js.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.