OpenAPI 3.1 export
Drop the spec into Scalar, Redoc, Swagger UI, or any spec-driven tool.
Every adapter serves an OpenAPI 3.1
document at <docsPath>/openapi.json and the docs UI ships with a one-click
Export to OpenAPI 3.1 button next to Export to Postman. The same
Zod-or-s-builder schemas that drive DocTreen's own UI also drive the spec —
no extra annotations, no separate file.
# Once the docs UI is running:
curl https://your-api.example.com/docs/openapi.json > openapi.jsonDrop the file (or paste it) into Scalar, Redoc, Swagger UI, editor.swagger.io, or any other tool that consumes the spec.
What's in the spec
- GET / POST / PUT / PATCH / DELETE operations grouped by tag (per-route
tagsoverride, otherwise first path segment) - Path parameters (
/users/:id→/users/{id}), query parameters fromrequest.query, request headers asparameters[].in = header - JSON request body for POST/PUT/PATCH with
requiredarrays derived from the Zod schema - 200 / 201 success responses (201 for POST) plus every declared error response with its own schema
components.schemaswith$refdedup v1.11 — named schemas fromdefineSchemaand repeated anonymous shapes are promoted automaticallycallbacksper-operation and document-levelwebhooksv1.11- Multi-example bodies and responses via
defineRoute({ examples })v1.11 - Top-level
tagsmetadata with descriptions and external docs v1.11 securitySchemes, per-routesecurity,hiddenroutes v1.8
Per-route tags + top-level metadata v1.11
expressAdapter(app, {
openapi: {
tags: [
{ name: 'users', description: 'User account management' },
{ name: 'billing', description: 'Invoices + payment methods',
externalDocs: { url: 'https://docs.example.com/billing' } },
],
},
});
app.post('/users', defineRoute(handler, {
tags: ['users', 'public'], // overrides the path-segment default
// ...
}));Tags used by routes but not declared at the top level are still emitted — just
without descriptions. The lint openapi command warns about undescribed tags
so you notice.
$ref schema deduplication v1.11
Wrap a schema with defineSchema('Name', ...) and every route that references
the same object will share a single $ref: '#/components/schemas/Name' entry
in the exported spec. See Named schemas for the full
walkthrough.
Callbacks and webhooks v1.11
OpenAPI 3.1 distinguishes between callbacks (per-operation, expressed inline
with a runtime expression like {$request.body#/callbackUrl}) and webhooks
(document-level events the server emits).
Per-operation callback
app.post('/payments', defineRoute(handler, {
description: 'Create a payment',
callbacks: {
onPaymentSucceeded: {
url: '{$request.body#/callbackUrl}',
method: 'POST',
summary: 'Notify the caller when the payment clears',
request: { body: s.object({ paymentId: s.string() }) },
response: s.object({ ok: s.boolean() }),
},
},
}));Document-level webhook
expressAdapter(app, {
openapi: {
webhooks: {
userDeleted: {
method: 'POST',
summary: 'Fired when a user closes their account',
request: { body: s.object({ userId: s.number(), deletedAt: s.string() }) },
response: s.object({ ok: s.boolean() }),
},
},
},
});Both reuse the same schema pipeline as routes — Zod schemas are accepted,
$ref dedup applies.
Multi-example bodies and responses v1.11
app.post('/users', defineRoute(handler, {
request: { body: User },
response: User,
errors: { 422: 'Validation failed' },
examples: {
request: {
basic: { value: { name: 'Ada', email: 'ada@example.com' }, summary: 'Minimum' },
admin: { value: { name: 'Boss', email: 'boss@x.com', role: 'admin' }, summary: 'With role' },
},
response: { id: 1, name: 'Ada', email: 'ada@example.com' },
responses: { 422: { value: { errors: ['email is required'] } } },
},
}));Single values render as OpenAPI example; named maps render as examples.
Aliases: body → request, success → response.
Linting the spec v1.11
# Live URL
npx doctreen lint openapi --url http://localhost:3000/docs
# Local file
npx doctreen lint openapi --file ./build/openapi.json --fail-on warning
# CI-friendly JSON
npx doctreen lint openapi --url https://api.example.com/docs --json
# Hide the noisy `info` items in the table
npx doctreen lint openapi --url http://localhost:3000/docs --no-infoNine rules across three severities:
- error — duplicate operationIds, missing
info.title/info.version, undeclared path parameters, operations withoutresponses - warning — missing operation
summary, missing 4xx response, undescribed tags - info — untagged operations, unused
components.schemasentries
--fail-on error (default) → exit 1 when any error is present. --fail-on warning
→ exit 1 on errors or warnings. info never affects exit code. Use --no-info
to suppress info-level rows from the table without changing the exit semantics.
Servers, security schemes, per-route security v1.8
Point the spec at real environments and declare auth schemes once — DocTreen
will attach the right security block to each operation and strip the
redundant Authorization header parameter:
expressAdapter(app, {
openapi: {
servers: [
{ url: 'https://api.example.com', description: 'Production' },
{ url: 'https://staging.api.example.com', description: 'Staging' },
],
securitySchemes: {
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
apiKey: { type: 'apiKey', in: 'header', name: 'x-api-key' },
},
security: [{ bearerAuth: [] }], // global default — applies to every operation
},
});Per-route overrides:
// Override with a different scheme
app.get('/admin/stats', defineRoute(handler, {
security: [{ adminAuth: [] }],
// ...
}));
// Mark this route explicitly public, ignoring the global default
app.post('/auth/login', defineRoute(handler, {
security: [],
// ...
}));When a route has any effective security requirement (per-route or inherited),
DocTreen automatically strips the Authorization header from parameters[] —
the security scheme is the single source of truth, and Redocly's
security-defined rule passes cleanly.
Inject custom HTML into the docs <head> v1.9
Pass headHtml to drop analytics scripts, custom CSS, favicons, OG tags, or
web fonts into the docs UI without forking DocTreen:
expressAdapter(app, {
meta: { title: 'My API', version: '1.0.0' },
headHtml: [
'<script defer src="/_vercel/insights/script.js"></script>',
'<script defer src="/_vercel/speed-insights/script.js"></script>',
'<link rel="icon" href="/favicon.ico" />',
'<meta name="theme-color" content="#0f1117">',
].join('\n'),
});The string is appended as-is to the generated <head>, after DocTreen's
built-in styles and before </head>. Trusted input — DocTreen does not
sanitise — so do not pass anything derived from user-submitted data.
Hide a route from the docs v1.8
Some endpoints serve traffic but should not appear in the docs UI or the OpenAPI export — internal admin tools, experimental features, deprecated routes you can't remove yet. Mark them per-route:
app.get('/internal/metrics', defineRoute(handler, {
hidden: true, // removed from docs UI and openapi.json — still serves 200s
description: 'Internal metrics endpoint',
}));// NestJS — full bag
@Get('flags') @DocRoute({ hidden: true }) flags() { /* ... */ }
// NestJS — shorthand decorator
@Get('flags') @DocHidden() flags() { /* ... */ }Hidden routes are filtered out by RouteRegistry.getVisible() (used by both
the docs UI and the OpenAPI exporter), so the runtime route remains fully
reachable.
CI hooks
npx @redocly/cli lint https://your-api.example.com/docs/openapi.json
npx @apidevtools/swagger-cli validate https://your-api.example.com/docs/openapi.json