Skip to main content

swagger-ui-express vs @hono/zod-openapi vs fastify-swagger: OpenAPI for Node.js (2026)

·PkgPulse Team

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

Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on swagger-ui-express v5.x, @hono/zod-openapi v0.18.x, and @fastify/swagger v8.x.

Compare API and documentation packages on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.