Instant API docs
for Express.
Like FastAPI's /docs, but for Node.js.
Zero annotations. Zero config. One line of code.
nodox automatically discovers your routes, reads Zod · Joi · yup · express-validator schemas without any changes to your code, and serves an interactive playground, all from a single middleware.
npm install nodox-cli
Every other approach makes you do the work.
API documentation has always been a second job. Annotations, decorators, YAML specs, manual schema registration; the tool expects you to write what the code already knows.
JSDoc comments, decorators, or OpenAPI YAML; documentation lives in a separate layer that drifts the moment you rename a field or add a param.
Tools that learn from live traffic can't show schemas for new routes until someone hits them. Fresh routes are invisible until real traffic arrives.
You already defined every field in Zod or Joi for runtime validation. Most tools don't read it, you define the same type twice, in two different formats.
Real API usage is sequential: create a resource, fetch it, act on it. Documentation tools show endpoints one at a time with no way to model dependencies.
One middleware line. Routes appear the moment they're registered. There is nothing else to write; the code is the documentation.
Schemas are read at registration time. Your test suite teaches it the rest. New routes are fully documented before a single user ever reaches them.
Zod, Joi, yup, express-validator, detected automatically at the framework level. One schema, zero duplication, always in sync.
Connect endpoints, map data between calls, and simulate entire multi-step API flows in the browser, with no extra tooling or setup.
app.use() call.
Everything your API docs need.
Nothing you don't.
Zero Config
Add one middleware. Routes appear instantly. No YAML, no JSDoc, no decorators, no code generators.
Auto Schema Detection
Reads Zod, Joi, yup, and express-validator schemas automatically. No wrapper functions required for detection.
Interactive Playground
Test every route directly from the browser. Path params, query strings, headers, and request body editors built-in.
Real-time Updates
WebSocket-powered live sync. The UI updates as routes are registered and schemas are learned, no refresh needed.
Test Suite Seeding
Run npx nodox init once. Your existing test suite automatically teaches nodox every request and response shape.
TypeScript Ready
Ships with full type definitions. No @types/ package needed. Works with Zod's inferred types out of the box.
Up in under two minutes.
npm install nodox-cli
import express from 'express'
import nodox from 'nodox-cli'
import { z } from 'zod'
const app = express()
app.use(express.json())
app.use(nodox(app)) // ← one line, that's it
const CreateUser = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().min(0).optional(),
})
// nodox detects the Zod schema automatically; no wrapper needed
app.post('/users', (req, res) => {
const result = CreateUser.safeParse(req.body)
if (!result.success) return res.status(400).json({ error: result.error })
res.status(201).json({ id: 1, ...result.data })
})
app.get('/users/:id', (req, res) => {
res.json({ id: Number(req.params.id), name: 'Alice', email: 'alice@example.com' })
})
app.listen(3000)
// → http://localhost:3000/__nodox
import express from 'express'
import nodox from 'nodox-cli'
import Joi from 'joi'
const app = express()
app.use(express.json())
app.use(nodox(app))
const CreateUser = Joi.object({
name: Joi.string().min(1).required(),
email: Joi.string().email().required(),
age: Joi.number().integer().min(0),
})
// nodox detects the Joi schema automatically; no wrapper needed
app.post('/users', (req, res) => {
const { error, value } = CreateUser.validate(req.body)
if (error) return res.status(400).json({ error: error.message })
res.status(201).json({ id: 1, ...value })
})
app.get('/users', (req, res) => {
res.json([{ id: 1, name: 'Alice' }])
})
app.listen(3000)
// → http://localhost:3000/__nodox
import express from 'express'
import nodox from 'nodox-cli'
import { body, validationResult } from 'express-validator'
const app = express()
app.use(express.json())
app.use(nodox(app))
// nodox reads ValidationChain metadata automatically;
// no validate() wrapper needed for express-validator
app.post('/users',
body('email').isEmail(),
body('name').isString().notEmpty(),
body('age').isInt({ min: 0 }).optional(),
(req, res) => {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() })
}
res.status(201).json({ id: 1, ...req.body })
}
)
app.listen(3000)
// → http://localhost:3000/__nodox
// Field types are detected: email → string/email, age → integer
import express from 'express'
import nodox from 'nodox-cli'
const app = express()
app.use(express.json())
app.use(nodox(app))
// No validation? No problem.
// nodox learns schemas from live traffic automatically.
app.get('/users', (req, res) => {
res.json([{ id: 1, name: 'Alice', role: 'admin' }])
})
app.post('/users', (req, res) => {
// Schema learned from the first real POST request
res.status(201).json({ id: 2, ...req.body })
})
app.listen(3000)
// → http://localhost:3000/__nodox
// Schemas populate as real requests arrive (observed confidence)
Open http://localhost:PORT/__nodox in your browser. Routes, schemas, and the playground are ready immediately.
Optionally, run npx nodox init once to wire your test suite as a schema source, no test changes needed.
Installation
npm install nodox-cli
# or
yarn add nodox-cli
# or
pnpm add nodox-cli
Basic Setup
Mount nodox before your route registrations and after your body parser. Passing the app reference enables real-time route interception.
import express from 'express'
import nodox from 'nodox-cli'
const app = express()
app.use(express.json()) // 1. Body parser first
app.use(nodox(app)) // 2. nodox second: pass app for real-time detection
// 3. Register your routes; all will be captured
app.get('/users', handler)
app.post('/users', handler)
app.listen(3000)
// → http://localhost:3000/__nodox
app.use(nodox(app)), they are still discovered via app._router.stack at startup, but real-time patching won't apply to them. Mount nodox first for best results.
Options
app.use(nodox(app, {
uiPath: '/__nodox', // URL prefix for the docs UI
log: true, // print startup info to console
schema: true, // enable schema detection
intercept: true, // enable live request/response interception
force: false, // allow running in production
server: undefined, // pass your http.Server when using http.createServer(app)
info: { // OpenAPI spec metadata (shown in external viewers)
title: 'My API',
version: '1.0.0',
},
}))
| Option | Type | Default | Description |
|---|---|---|---|
uiPath | string | '/__nodox' | URL prefix for the documentation UI and WebSocket endpoint. |
log | boolean | true | Print route count and UI URL to the console at startup. |
schema | boolean | true | Enable automatic schema detection (Zod/Joi/yup/express-validator). |
intercept | boolean | true | Wrap res.json() to observe request/response shapes from live traffic. |
force | boolean | false | Allow nodox to run when NODE_ENV=production. See Production. |
server | http.Server | undefined | Pass your http.Server instance when using http.createServer(app) + server.listen() instead of app.listen(). Without it nodox still works but the startup log shows "localhost:PORT" until the first request arrives. |
info | object | undefined | OpenAPI spec metadata shown in external viewers (Scalar, Redocly, Swagger UI). Accepts title, version, description, contact, license, termsOfService. |
validate() — optional, Layer 5
Skip it entirely and Layers 1–4 still document every route. Use it on specific routes when auto-detection isn't enough.
When to reach for it
| Scenario | Why validate() helps |
|---|---|
| Bundler / transpiler in use (esbuild, SWC, Babel, tsc) | Layer 1 reads fn.toString(); mangled source won't match. validate() passes the schema object directly. |
| Runtime enforcement needed | Layers 1–4 document only; they never enforce. validate() runs the schema on every real request and returns a structured 400 on failure. |
| Response schema | No layer auto-detects what a route returns. validate() accepts a responses map per status code. |
| Metadata (tags, auth, summary, deprecated) | Not detectable from code. validate() is the only way to attach it. |
import { validate } from 'nodox-cli'
// Runtime enforcement + response schema
app.post('/users',
validate(CreateUser, {
responses: { 201: UserResponse, 400: ErrorResponse },
}),
handler
)
// With metadata
app.post('/users',
validate(CreateUser, {
tags: ['Users'],
auth: { type: 'bearer' },
meta: { summary: 'Create a user', deprecated: false },
}),
handler
)
// Strict mode: reject unknown fields
app.post('/users', validate(CreateUser, { strict: true }), handler)
Supported libraries
| Library | Runtime validation | Response docs |
|---|---|---|
| Zod v3 + v4 | ✓ safeParse | ✓ |
| Joi | ✓ validate | ✓ |
| yup | ✓ validateSync | ✓ |
| Valibot | ✓ safeParse | ✓ |
| TypeBox | ✓ Value.Check | ✓ |
| Plain JSON Schema | ~ Display only | ✓ |
Validation error format
On failure, validate() returns 400 with:
{
"error": "Validation failed",
"details": [
{ "path": "email", "message": "Invalid email address", "code": "invalid_string" }
]
}
Pass { problemDetails: true } to switch to RFC 7807 format.
Full signature
function validate(
schema: object, // Zod | Joi | yup | Valibot | TypeBox | JSON Schema
options?: {
strict?: boolean // reject unknown fields
responses?: Record<number, object> // per-status response schemas
tags?: string[] // sidebar grouping
auth?: { type: 'bearer' | 'apiKey' | 'basic' | 'oauth2', ... }
meta?: { summary?, description?, deprecated?, examples? }
externalDocs?: { url: string, description?: string }
problemDetails?: boolean // RFC 7807 error format
}
): RequestHandler
express-validator
nodox detects express-validator chains directly from their internal metadata at route-registration time. No validate() wrapper needed.
import { body, param } from 'express-validator'
// nodox reads these chains automatically
app.post('/users',
body('email').isEmail(), // → string, format: email
body('name').isString(), // → string
body('age').isInt({ min: 0 }), // → integer
body('active').isBoolean(), // → boolean
handler
)
Inferred type mapping
| Validator | JSON Schema type |
|---|---|
isEmail() | string, format: email |
isURL() | string, format: uri |
isUUID() | string, format: uuid |
isISO8601() | string, format: date-time |
isInt() / isNumeric() | integer |
isFloat() / isDecimal() | number |
isBoolean() | boolean |
isArray() | array |
isJSON() | object |
| anything else | string |
Schema Detection Layers
Layers 1–4 run automatically against your existing code. Layer 5 is optional; use it only when the automatic layers aren't giving you what you need.
| Layer | Source | Confidence | Trigger | Bundler-safe? |
|---|---|---|---|---|
| 1 | Source-code scan (Zod / Joi / yup / express-validator) | inferred | Route registration | ~ Plain source only |
| 2 | Dry-run with mock request | inferred | Startup tick | ~ Plain source only |
| 3 | Test suite cache (.apicache.json) | observed | Server startup | ✓ Yes |
| 4 | Live res.json() interception | observed | Real traffic | ✓ Yes |
| 5 (optional) | validate() wrapper | confirmed | Route registration | ✓ Yes |
A higher-confidence result is never overwritten by a lower one. When validate() is present on a route, its schema wins. When it isn't, the best result from Layers 1–4 is used.
Test Suite Seeding
nodox can learn every request and response shape from your existing test suite, no test changes needed.
npx nodox init # detects Jest or Vitest, injects setup file
This adds nodox-cli/jest-setup to your setupFiles. The setup file patches http.request during tests to record every local HTTP exchange and writes shapes to .apicache.json on process exit.
On next server startup, schemas from .apicache.json are pre-loaded, including parameterized routes like /users/:id matched from test calls to /users/123.
npx nodox prune after significant API changes to wipe and start fresh.
API Versioning
nodox detects API versions from route path prefixes (/v1/, /v2/, /v3/, etc.) automatically, no configuration needed.
app.get('/api/v1/users', handler) // grouped under v1
app.get('/api/v2/users', handler) // grouped under v2
When multiple versions are present, the sidebar shows a version filter strip:
[ALL] [v1] [v2]
Routes are also auto-tagged in the OpenAPI export so external viewers like Scalar, Redocly, and Swagger UI group them correctly. Tags declared via validate() take precedence over auto-detected version tags.
OpenAPI Export
nodox serves a live OpenAPI 3.1 spec generated from the same schema data shown in the UI. No build step or separate tool needed.
GET /__nodox/openapi.json
GET /__nodox/openapi.yaml
The spec updates in real time as new schemas are observed. One-click links to both endpoints are available in the sidebar footer of the docs UI.
To customize the spec's info block (title, version, contact, license) for use in external viewers, pass the info option:
app.use(nodox(app, {
info: {
title: 'My API',
version: '2.0.0',
description: 'Internal service API',
contact: { name: 'Platform Team', email: 'platform@example.com' },
},
}))
/__nodox/openapi.json, which is protected by the same production-disable logic as the UI. Never expose these endpoints publicly without authentication.
CLI Reference
| Command | Description |
|---|---|
npx nodox init | Detect Jest/Vitest and inject nodox-cli/jest-setup into the config. Adds .apicache.json to .gitignore. |
npx nodox status | Show cache stats: route count, schemas with input/output, last updated. |
npx nodox prune | Wipe .apicache.json and start fresh. Use after major API changes. |
npx nodox snapshot | Save the current /__nodox/openapi.json as a baseline snapshot for diffing. |
npx nodox diff | Compare the current OpenAPI spec against the saved snapshot and report breaking changes. |
TypeScript
nodox ships with complete type definitions. No additional @types/ package needed.
import nodox, { validate, NodoxOptions, ValidateOptions } from 'nodox-cli'
import type { RequestHandler } from 'express'
import { z } from 'zod'
const opts: NodoxOptions = {
uiPath: '/api-docs',
force: false,
}
app.use(nodox(app, opts))
const Schema = z.object({ name: z.string() })
const handler: RequestHandler = (req, res) => {
res.json({ ok: true })
}
app.post('/items', validate(Schema), handler)
tsconfig.json uses "moduleResolution": "bundler", "node16", or "nodenext" to resolve the exports field in package.json.
Production Safety
nodox is disabled by default when NODE_ENV=production. It returns a no-op middleware and prints a warning.
[nodox] Disabled in production (NODE_ENV=production).
Pass { force: true } to override, but do not expose /__nodox publicly.
To intentionally enable nodox in a non-development environment (for example, behind an internal auth gateway):
app.use(nodox(app, { force: true }))
/__nodox publicly without authentication. It reveals your full API surface including route structure, parameter names, and schema shapes, and provides a working request playground.
Compatibility
| Dependency | Version | Status |
|---|---|---|
| Node.js | ≥ 18.0.0 | ✓ Required |
| Express | 4.x · 5.x | ✓ Fully supported |
| Zod | v3 · v4 | ✓ Auto-detected |
| Joi | ≥ 17 | ✓ Auto-detected |
| yup | ≥ 0.32 | ✓ Auto-detected |
| express-validator | v6 · v7 | ✓ Auto-detected |
| Jest | ≥ 27 | ✓ npx nodox init |
| Vitest | ≥ 0.28 | ✓ npx nodox init |
Common issues & solutions.
Everything developers run into, and how to fix it.
Schema not showing in the UI
Cause: Layers 1–2 couldn't detect the schema (usually a bundler/transpiler mangling source), and no requests have flowed through yet for Layers 3–4 to observe.
Fix, pick one:
- Make a request: hit the route once (manually or via your test suite) and Layer 4 live interception will populate it.
- Run your test suite: run
npx nodox initonce, thennpm test; Layer 3 will pre-load schemas from your test data on next startup. - Use
validate(): only needed if you're on a bundler (source scan can't run) or you want confirmed schemas immediately at startup with no traffic required.
// Only needed when on a bundler or want immediate confirmed schema
import { validate } from 'nodox-cli'
app.post('/users', validate(schema), handler)
Playground shows "No body parser detected"
Cause: express.json() is not mounted, or is mounted after nodox.
Fix: Add app.use(express.json()) before app.use(nodox(app)).
app.use(express.json()) // ← first
app.use(nodox(app)) // ← second
Routes from sub-routers not appearing
Cause: app.use('/api', router) was called before app.use(nodox(app)). The app-patcher couldn't intercept the mount.
Fix: Mount nodox as early as possible, before any app.use() calls that register routers.
app.use(express.json())
app.use(nodox(app)) // ← mount before routers
app.use('/api/v1', v1Router)
app.use('/api/v2', v2Router)
nodox is disabled: no-op in production
Cause: NODE_ENV=production is set. nodox disables itself to prevent accidental exposure.
Fix: This is intentional. If you need nodox on staging (behind auth), pass { force: true }. Otherwise set NODE_ENV=development locally.
// Staging only: ensure auth is in front
app.use(nodox(app, { force: true }))
WebSocket not connecting (WS error in browser)
Cause: A reverse proxy (nginx, Caddy, HAProxy) is not forwarding WebSocket upgrade requests.
Fix: Configure your proxy to pass through WebSocket connections on the /__nodox_ws path.
# nginx example
location /__nodox_ws {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
npx nodox init says "No Jest or Vitest config found"
Cause: The command was run from a subdirectory, or the project uses an unusual config file name.
Fix: Run it from your project root (where package.json lives). Supported: jest.config.js/ts/json, vitest.config.js/ts, or a "jest" key in package.json.
TypeScript can't find nodox types
Cause: moduleResolution is set to "node" (legacy), which doesn't read the exports field in package.json.
Fix: Update tsconfig.json:
{
"compilerOptions": {
"moduleResolution": "bundler"
}
}
Dry-run causes unexpected behavior at startup
Cause: nodox calls middleware with a mock request to detect schemas. If a function has side effects (rate-limiter increments, audit logs, DB writes), they fire once at startup.
Fix: Use validate(schema) on affected routes; it registers at route-creation time, completely skipping the dry-run.
Schema not detected after switching to a bundler
Cause: The dry-run source screener inspects fn.toString() for validator patterns. After bundling/minification with esbuild, SWC, or Babel, function source is unrecognizable.
Fix: Use validate(); it registers at function creation time, not source inspection time.
.apicache.json not updating after test runs
Cause: The setup file was not injected, or tests use a test server that doesn't make real HTTP requests to localhost.
Fix: Run npx nodox status to check the cache. If empty, re-run npx nodox init and ensure your tests use supertest or real http.request calls.
Schema shows wrong structure after API changes
Cause: .apicache.json is add-only; old fields are never removed. Stale data from prior test runs persists.
Fix: Run npx nodox prune to wipe the cache, then re-run your test suite.
npx nodox prune # wipe stale cache
npm test # rebuild from tests
nodox() called multiple times: duplicate routes
Cause: app.use(nodox()) called more than once, or imported in both a route file and the main server file.
Fix: Call nodox() exactly once in your main server entry point. It's a singleton per app instance.
Read before you ship.
Important caveats to understand before using nodox in any environment.
Never expose /__nodox publicly without authentication
The nodox UI reveals your complete API surface: every route path, parameter name, schema shape, and a fully functional request playground. An attacker with access can enumerate your entire API and send arbitrary requests through the playground.
nodox is disabled by default in production (NODE_ENV=production). If you enable it with force: true, ensure it is protected by authentication middleware.
The playground sends real requests with real effects
Every request you send from the nodox playground hits your actual server, executes your actual route handlers, and can write to your actual database or trigger side effects. It is not a mock environment.
Use test data. Be aware of what each endpoint does before sending requests from the playground in any non-isolated environment.
Dry-run fires actual middleware code at startup
When nodox detects a possible validator via source screening, it calls that middleware with a mock request during the startup tick. If the function has side effects: rate-limiter increments, audit log entries, external API calls, database writes, those will execute once at server startup.
Use validate(schema) for routes whose handlers have side effects. This registers the schema directly and completely skips the dry-run.
Source screening breaks under bundlers and minifiers
Layer 1 (source screener) inspects fn.toString() for Zod/Joi patterns. After bundling with esbuild, SWC, Babel, or webpack, function source is unrecognizable and source screening produces no results. Layer 2 (dry-run) is also skipped because it depends on Layer 1 flagging a handler first.
If your dev server runs through a bundler, rely on test suite seeding (Layer 3), live interception (Layer 4), or validate() (Layer 5) on specific routes for schema coverage.
.apicache.json accumulates over time
The test suite cache is add-only; it merges new fields but never removes old ones. After significant API changes (renamed fields, removed routes), the cache will contain stale data that appears in the UI.
Run npx nodox prune after major API refactors to clear the cache and rebuild it from a fresh test run.
nodox reads Express internals (app._router)
Route extraction depends on app._router.stack, which is an undocumented internal of Express. This is stable across Express 4 and 5, but could change in a future major version.
nodox checks the Express version at startup and warns if it's outside the tested range (4.x–5.x).
WebSocket uses ws:// not wss:// by default
The real-time update connection between the nodox UI and your server uses an unencrypted WebSocket (ws://). This is fine for local development (localhost). For remote access through a proxy, configure the proxy to terminate TLS and forward the connection.
The WebSocket is served at /__nodox_ws and only accepts connections from the same origin (same host) to prevent cross-site reads.
File paths are shown relative to project root
When using validate(), nodox captures the file path where the schema was defined and shows it in the UI (e.g., src/routes/users.js:42). This is relative to your project root, not absolute, no server filesystem paths are exposed to the browser.
Schema confidence never downgrades
Once a schema is registered at a given confidence level, it cannot be replaced by a lower-confidence source. A confirmed schema (from validate()) will never be overwritten by observed data from live traffic or the test cache.
This is intentional; it prevents noisy real-world traffic from corrupting your documented schema. Use validate() to lock in the authoritative schema.
Frequently asked questions.
No. Schema detection runs in a deferred setTimeout tick after startup and never blocks request handling. The response interceptor (res.json() wrapper) adds a structurally-constant overhead of shape inference, microseconds per request, negligible in practice. The WebSocket connection only carries metadata about routes and schemas, not request payloads.
Yes. nodox ships with index.d.ts and jest-setup.d.ts. All public types are exported: NodoxOptions, ValidateOptions, and the validate() function. Ensure moduleResolution is set to "bundler" or "node16" in your tsconfig.json.
confirmed: you explicitly registered the schema via validate(). This is 100% authoritative and never overwritten.
inferred: nodox detected the schema automatically (via express-validator chain metadata or dry-running a validator). Reliable but indirect.
observed: nodox learned the schema by watching real requests and responses (live interception or test seeding). Structural inference only, might miss optional fields that weren't present in observed traffic.
Not directly. nodox is disabled by default when NODE_ENV=production. If you want it accessible in a non-development environment, for example, on a private internal staging server, set { force: true } and ensure the /__nodox path is protected by authentication middleware before it reaches nodox.
We strongly recommend keeping it development-only.
No. nodox is designed specifically for Express and relies on Express internals (app._router.stack) for route extraction and the Express middleware signature for interception. Support for other frameworks is not planned at this time.
swagger-jsdoc requires you to write OpenAPI spec in JSDoc comments on every route. swagger-autogen requires a separate generation script you re-run manually after every route change. Both produce static output that goes stale as your API changes.
nodox requires zero annotations and updates live. It also serves a live OpenAPI 3.1 spec at /__nodox/openapi.json and /__nodox/openapi.yaml you can paste into Swagger UI, Scalar, or any OpenAPI viewer.
Not yet. The UI is a built React bundle served by the middleware. Custom UI themes and manual annotation support are planned for a future release. For now, the validate() response schema option is the primary way to add documentation beyond what nodox can detect automatically.
Route detection works for any route, including file upload routes. However, schema inference from req.body only works when a body parser populates it. Multer and similar middleware don't expose form fields through req.body in the same way, so field schemas for file upload routes are typically not auto-detected. Use validate() with a plain JSON Schema to document multipart routes.
The nodox UI auto-reconnects with exponential backoff after a server restart. When the connection is restored, the server sends a full state sync message and the UI completely replaces its state, no stale routes from the previous instance persist.
Yes, with one requirement: your proxy must forward WebSocket upgrade requests to the /__nodox_ws path. The static UI (/__nodox) works through any proxy that forwards standard HTTP. See the WebSocket not connecting issue for an nginx configuration example.