API documentation that works the moment you run your server. No annotations, no YAML, no setup.
nodox-cli is an Express middleware that automatically discovers every route in your app, detects request/response schemas using a 5-layer pipeline, and serves a live interactive docs UI at /__nodox. Think FastAPI's /docs, but for Node.js. The first time you see it, your entire API is already there.
npm install nodox-cliAnnotation-based tools start empty: you annotate routes, write YAML, run generators. Traffic-based tools show routes but leave them schema-less until you hit every endpoint manually. Either way, documentation is a separate project you maintain alongside your actual code.
nodox-cli is different. Add one line and your existing routes are immediately documented, with detected schemas, an interactive playground, and live schema updates as real requests flow through.
| nodox-cli | express-oas-generator | swagger-jsdoc | tsoa | swagger-autogen | Postman | |
|---|---|---|---|---|---|---|
| Setup | One middleware line | Two middleware placements (before + after routes) | Config file + annotated route files | TypeScript decorators + codegen step | Separate generate script; re-run after route changes; swagger-ui-express for UI |
Manual collection or CLI generator |
| Annotate every route? | No | No | Yes (@swagger JSDoc on every route) |
Yes (class decorators) | No for route listing; #swagger.* comments needed for body and response schemas |
No (but no Express integration) |
| Schema without hitting routes? | Yes, automatic, zero annotations | No, needs real traffic | Via manual @swagger annotations only |
Via manual TypeScript decorators only | Partial: scans req.body access patterns; no Zod/Joi/yup detection |
No |
| Live request playground | Yes, built-in | Via Swagger UI | Via Swagger UI add-on | Via Swagger UI add-on | Via Swagger UI (swagger-ui-express) |
Separate app |
| Schema from real traffic | Yes (Layer 4) | Yes (only mechanism) | No | No | No | No |
| Multiple schema detection layers | Yes (5 layers) | No | No | No | No | No |
| Chain builder / flow simulation | Yes | No | No | No | No | Separate Flows tool |
| OpenAPI 3.1 export | Yes, at /__nodox/openapi.json |
OpenAPI 3.0 natively (not 3.1); Swagger 2.0 default | Yes (any version you configure) | Yes, 3.1 default since v7 | Static .json file; OpenAPI 3.0.0 max (Swagger 2.0 default) |
Manual export from Spec Hub |
import express from 'express'
import nodox from 'nodox-cli'
const app = express()
app.use(express.json())
app.use(nodox(app)) // add this line
app.get('/users', handler)
app.post('/users', handler)
app.listen(3000)
// β docs live at http://localhost:3000/__nodoxEvery route you've already written will appear in the UI. No annotations, no changes to your existing handlers.
You can also call nodox() without passing app; it detects the Express app automatically from the first incoming request:
app.use(nodox()) // app detected from req.app at runtimePassing app explicitly enables Layer 1 source screening immediately at startup rather than waiting for the first request.
nodox-cli uses a 5-layer pipeline to detect request/response schemas. Layers 1β4 run automatically against your existing code, no changes needed.
| Layer | Source | What it does |
|---|---|---|
| 1 | Source-code heuristic scan | Parses route handler source for Zod / Joi / yup / express-validator references |
| 2 | Dry-run with mock request | Calls the handler with a synthetic request, observes what it reads and validates |
| 3 | Test suite recording (.apicache.json) |
Loads shapes recorded from your real test suite |
| 4 | Live res.json() interception |
Intercepts actual responses as they happen in development |
| 5 (optional) | validate() wrapper |
Explicitly attach a schema when auto-detection isn't enough; produces a confirmed badge and validates req.body at runtime |
express-validator chains are detected automatically in Layer 1, no wrapper needed. If your routes use check(), body(), or param() validation chains, nodox-cli extracts field names and detects types directly from the validator names (isEmail, isInt, isUUID, etc.).
Layer 1 and bundlers/minifiers: Layer 1 reads handler source code as a string to detect validation patterns. If your dev server runs code through Babel, SWC, esbuild, or
tsc, the source is mangled and Layer 1 cannot match patterns. Fall back to Layers 3β4 (test cache + live interception), or usevalidate()on that specific route.
Layers 1β4 run entirely on their own against your existing code, no extra lines needed. If a handler has no validation logic at all (just reading req.body directly), request body schema will be populated once real traffic flows through Layer 4. Response schema detection is unaffected.
Layer 2 runs in a sandbox. The dry-run calls your handler with a mock request but blocks all outgoing network connections, database calls, and filesystem writes; nothing is executed for real.
validate() is Layer 5; skip it entirely and Layers 1β4 still document every route. Use it on specific routes when you hit one of these cases:
Bundler/transpiler in use: Babel, SWC, esbuild, or tsc mangle source code so Layer 1 can't read patterns. validate() passes the schema object directly, bypassing source scanning entirely.
import { validate } from 'nodox-cli'
import { z } from 'zod'
const CreateUserSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().int().optional(),
})
app.post('/users', validate(CreateUserSchema), async (req, res) => {
// req.body is already validated and coerced, safe to use directly
const user = await db.createUser(req.body)
res.status(201).json(user)
})Runtime validation needed: Layers 1β4 only document; they never enforce. validate() runs the schema against req.body on every real request and returns a structured 400 on failure, with no if (!result.success) boilerplate needed.
// without validate(): you handle validation yourself
app.post('/users', async (req, res) => {
const result = CreateUserSchema.safeParse(req.body)
if (!result.success) return res.status(400).json({ error: result.error })
const user = await db.createUser(result.data)
res.status(201).json(user)
})
// with validate(): middleware handles the 400, handler only runs on valid input
app.post('/users', validate(CreateUserSchema), async (req, res) => {
const user = await db.createUser(req.body)
res.status(201).json(user)
})Response schema: no layer auto-detects what your route returns. Pass a responses map keyed by status code.
const UserResponse = z.object({ id: z.number(), name: z.string(), email: z.string() })
const ErrorResponse = z.object({ error: z.string(), details: z.array(z.string()) })
app.post('/users', validate(CreateUserSchema, {
responses: {
201: UserResponse,
400: ErrorResponse,
},
}), handler)Metadata: tags, auth scheme, summary, deprecated flag, examples. None of this is detectable from code; validate() is the only way to attach it.
app.post('/users', validate(CreateUserSchema, {
tags: ['Users'],
auth: { type: 'bearer' },
meta: {
summary: 'Create a new user',
description: 'Creates a user and returns the created record.',
deprecated: false,
examples: { body: { name: 'Jane Doe', email: 'jane@example.com' } },
},
}), handler)All four options can be combined in a single validate() call. Accepts Zod (v3 and v4), Joi, yup, Valibot, TypeBox, and plain JSON Schema.
β Full validate() reference: all options, libraries, error formats, tags, auth, examples
Record real request/response shapes from your existing tests automatically, no changes to test code required:
npx nodox init # injects nodox-cli/jest-setup into your Jest/Vitest configinit also adds .apicache.json to your .gitignore automatically if one exists.
Recorded shapes are stored in .apicache.json and loaded on the next server start. This is Layer 4: shapes observed from real test data, not synthesized. New observations are merged into existing entries rather than overwriting them. nodox-cli searches for .apicache.json upward from your working directory (up to 5 levels), so monorepo setups with a cache at the workspace root are supported without any path configuration.
Run npx nodox prune to reset the cache.
nodox-cli automatically detects API versions from route prefixes (/v1/, /v2/, /v3/, etc.) with no configuration needed. When multiple versions are present, the sidebar gains a version filter strip and groups routes by version:
[ALL] [v1] [v2]
Routes are also auto-tagged in the OpenAPI export so external viewers like Scalar, Redocly, and Swagger UI group them correctly.
app.get('/api/v1/users', handler) // β version: v1, tag: v1
app.get('/api/v2/users', handler) // β version: v2, tag: v2When a route has tags declared via validate() (Layer 5), those take precedence over the auto-detected version tag.
- Schema tab: field names, types, required badges, confidence indicator, per-status response schemas, example code blocks, and auth info block when declared
- Tag grouping: routes with
tagsdeclared viavalidate()are grouped under named headers in the sidebar; routes without tags appear below all groups - Version grouping: when multiple API versions are detected (e.g.
/v1/,/v2/), routes are automatically grouped by version with a version filter strip at the top of the sidebar - Auth badges: routes with
authdeclared show a lock badge (π) in the sidebar row and in the detail panel header, with full scheme details in the Schema tab. Common auth middleware functions (authenticate,requireAuth,jwtAuth, etc.) are detected automatically even without an explicitauthdeclaration. - Deprecated routes: routes with
meta.deprecated: trueshow a strikethrough path and "deprecated" badge in the sidebar; the detail panel header also shows the badge - Rich metadata:
meta.summaryandmeta.descriptionrender beneath the route path in the detail panel;meta.descriptionsupports inline Markdown (bold, italic, inline code, links);meta.examplesrender as code blocks and fill the Playground - External docs link: routes with
externalDocsdeclared show a clickable link in the detail panel header; also emitted asexternalDocsin the OpenAPI export - Playground: send live requests directly from the browser; path params render as inline inputs; body fields are pre-filled from detected schema; click Load example to pre-fill from
meta.examples.body; query parameters are documented for GET, DELETE, HEAD, and OPTIONS routes - Code snippets: expand the "Code snippets" section in the Playground to copy a ready-to-run cURL, JavaScript fetch, or Python requests command built from the current route, path params, headers, and body
- Middleware chain: the detail panel lists every function in the route's handler stack by name, so you can see at a glance which guards, validators, and handlers apply to each route
- Chain builder: connect routes on a canvas, wire output fields to input fields, and simulate multi-step flows with
{{step0.fieldName}}interpolation - Environment switcher: swap the base URL between local, staging, and production without leaving the UI
- Response diff: save a baseline response and compare it against subsequent calls to catch regressions
- Dark / light mode: toggle between dark (default) and light themes using the β/β button in the header; preference is saved across sessions
- OpenAPI export links: one-click links in the sidebar footer open
/__nodox/openapi.jsonand/__nodox/openapi.yamlin new tabs
Every field in the UI is marked with a badge showing how nodox got its schema:
| Badge | How it got there |
|---|---|
| confirmed | validate() was used on this route |
| inferred | nodox dry-ran your handler or read express-validator chains (Layers 1β2) |
| observed | nodox watched real traffic or loaded from .apicache.json (Layers 3β4) |
| (none) | Route has no data yet |
The Chain tab lets you wire routes together into a multi-step flow and execute them in sequence, without leaving the docs UI.
Drop routes onto the canvas, connect them, and use {{step0.fieldName}} to pass values from one step's response into the next step's input. No copy-pasting between requests.
β Full chain builder guide: syntax, examples, auth flows
app.use(nodox(app, {
uiPath: '/__nodox', // URL prefix for the docs UI
log: true, // print startup banner with route count and URL
schema: true, // enable schema detection pipeline
intercept: true, // enable live res.json() interception (Layer 4)
force: false, // allow running in NODE_ENV=production
server: undefined, // pass your http.Server when using http.createServer(app) + server.listen()
// instead of app.listen(). Without it nodox still works correctly but the
// startup log shows "localhost:PORT" until the first request arrives.
// Recommended pattern:
// const httpServer = http.createServer(app)
// app.use(nodox(app, { server: httpServer }))
// httpServer.listen(3000)
// OpenAPI spec info shown in external viewers (Scalar, Redocly, Swagger UI)
info: {
title: 'My API',
version: '2.0.0',
description: 'Internal service API',
contact: { name: 'Platform Team', email: 'platform@example.com' },
license: { name: 'MIT', url: 'https://opensource.org/licenses/MIT' },
termsOfService: 'https://example.com/tos',
},
}))nodox-cli is a no-op in production by default (NODE_ENV=production). Pass force: true to override, but do not expose /__nodox publicly, as it reveals all routes, detected schemas, and a full request playground.
nodox-cli serves a live OpenAPI 3.1 spec in both JSON and YAML, generated from the same schema data shown in the UI:
GET http://localhost:3000/__nodox/openapi.json
GET http://localhost:3000/__nodox/openapi.yaml
The spec includes all discovered routes, path and query parameters, request/response schemas, tags, auth security schemes, deprecation flags, and reusable components/schemas entries so SDK generators produce named types rather than anonymous inline shapes. Both endpoints are CORS-open (Access-Control-Allow-Origin: *).
Paste either URL directly into Swagger UI, Redocly, Scalar, or any OpenAPI-compatible viewer. SDK generators like Speakeasy or Fern can consume it without any extra configuration.
npx nodox init # set up test suite integration (Jest or Vitest); updates .gitignore
npx nodox prune # clear .apicache.json
npx nodox status # print per-route schema coverage (live server or cache fallback)
npx nodox snapshot # save a baseline OpenAPI snapshot for diff
npx nodox diff # compare snapshots and report breaking changesβ Full CLI reference: options, output examples, CI setup
Mode 1: Zero config. Add one line, nodox handles everything.
app.use(nodox(app))Mode 2: Patch specific routes. Let auto-detection handle most routes, drop validate() only where you need runtime enforcement or metadata.
app.use(nodox(app))
app.get('/users', handler) // auto-detected
app.post('/users', validate(CreateUserSchema), handler) // confirmed + runtime validation
app.get('/users/:id', handler) // auto-detected
app.delete('/users/:id', validate(IdSchema), handler) // confirmed + runtime validationMode 3: Disable auto-detection. Turn off Layers 1β4 and declare everything manually with validate().
app.use(nodox(app, { schema: false, intercept: false }))
app.post(
'/users',
validate(CreateUserSchema, {
tags: ['Users'],
responses: { 201: UserResponse, 400: ErrorResponse },
strict: true,
}),
handler
)Type declarations are included. The package is ESM-first with a CJS fallback.
import nodox, { validate } from 'nodox-cli'
import type { NodoxOptions, NodoxInfoOptions, ValidateOptions } from 'nodox-cli'validate() is generic; when passed a Zod schema, the inferred output type flows into req.body in the next handler:
import { z } from 'zod'
import { validate } from 'nodox-cli'
const CreateUser = z.object({ name: z.string(), email: z.string().email() })
app.post('/users',
validate(CreateUser), // req.body inferred as { name: string; email: string }
(req, res) => {
const { name, email } = req.body // fully typed, no cast needed
res.status(201).json({ id: 1, name, email })
}
)Both Zod v3 and Zod v4 are supported. nodox-cli detects the installed version automatically and uses the appropriate patching strategy for each.
- Node.js β₯ 18
- Express β₯ 4 (Express 5 is supported)
- Schema libraries: Zod v3 and v4, Joi, yup, Valibot, TypeBox, express-validator v6 and v7, plain JSON Schema
Valibot and TypeBox are optional peer dependencies; install whichever you use:
npm install valibot # for Valibot support
npm install @sinclair/typebox # for TypeBox supportNeither is required. nodox-cli detects them at runtime if installed.
See CONTRIBUTING.md.
MIT