swagger-ui-express vs @hono/zod-openapi vs fastify-swagger: OpenAPI for Node.js (2026)
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
Download Trends
| Package | Weekly Downloads | Approach | Type Safety | Framework |
|---|---|---|---|---|
swagger-ui-express | ~2M | Manual / jsdoc | ❌ | Express/any |
@hono/zod-openapi | ~300K | Zod → OpenAPI | ✅ Excellent | Hono |
@fastify/swagger | ~1M | JSON Schema → OpenAPI | ✅ | Fastify |
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
| Feature | swagger-ui-express | @hono/zod-openapi | @fastify/swagger |
|---|---|---|---|
| Framework | Any (Express focus) | Hono | Fastify |
| Schema language | Manual YAML/JSON | Zod | JSON 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.