OpenAPI & Swagger
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm @nestjs/core 12.1.2, published 2026-09-30) and @nestjs/swagger 12.0.2
(published 2026-09-23, bundling Swagger UI 5.33.0), Node.js 22 and 24 LTS (the package requires Node.js 20.19+ or 22.12+),
TypeScript 6 (6.0.x), Express 5.2 and Fastify 5.12, all checked on 2026-10-02. The examples are exercised with Vitest and
supertest on Nest 12, the document and the Swagger UI routes behave the same on the Express and the
Fastify adapter, and the CLI plugin examples build with nest build and run on Node.js 24.16. An API without a
machine-readable description is an API that every consumer has to reverse-engineer. The package @nestjs/swagger reads the
metadata that Nest already holds about controllers and DTOs, lets you complete it with decorators (or a compiler plugin),
and produces an OpenAPI document that Swagger UI renders as an interactive page and that code generators turn into typed
clients. The examples continue the Bookshelf scenario used throughout this section.
OpenAPI, Swagger UI and what the package does
Three names are easy to confuse:
-
OpenAPI is a language-agnostic specification for describing HTTP APIs: paths, operations, parameters, request and response bodies, schemas and security. A document is plain JSON or YAML.
@nestjs/swaggergenerates OpenAPI 3.0 documents by default;DocumentBuilder.setOpenAPIVersion()selects 3.1 or 3.2 (the examples below show what each changes). -
Swagger UI is the browser front end that renders an OpenAPI document and lets you call the API. The package bundles it (
swagger-ui-dist) and serves it from your own application. -
@nestjs/swaggeris the Nest module that connects the two. It is a library, not part of Nest core: it addsSwaggerModuleandDocumentBuilder, a family of@Api…decorators, optional mapped-type helpers and an optional compiler plugin. It requires NestJS 12 (@nestjs/commonand@nestjs/core^12.0.0).
The diagram shows the two halves of the job. Decorators and DocumentBuilder are only data: nothing is generated until
createDocument() walks the module graph and turns that metadata into one document object. What you do with the object is
up to you: serve it (setup()), write it to a file in CI, or both.
Installation and bootstrap
Install the package. On Fastify, also install @fastify/static, which the package uses to serve the Swagger UI assets:
npm install @nestjs/swagger
npm install @fastify/static # Fastify only
The minimal bootstrap builds a base document with DocumentBuilder, wraps createDocument() in a factory, and mounts the
UI. This page keeps the configuration in a function so that the same code serves the application, the tests and the build
step that writes the spec to disk:
import type { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule, type SwaggerCustomOptions, type SwaggerDocumentOptions } from '@nestjs/swagger';
export function setupOpenApi(app: INestApplication, setupOptions: SwaggerCustomOptions = {}) {
const config = new DocumentBuilder()
.setTitle('Bookshelf API')
.setDescription('Books, authors and reviews')
.setVersion('1.0')
.setContact('Bookshelf team', 'https://example.com/support', 'api@example.com')
.setLicense('MIT', 'https://opensource.org/license/mit')
.addServer('http://localhost:3000', 'Local development')
.addTag('books', 'Catalogue of books')
.addBearerAuth()
.addApiKey({ type: 'apiKey', in: 'header', name: 'X-Api-Key' }, 'api-key')
.addBasicAuth()
.addOAuth2({
type: 'oauth2',
flows: { implicit: { authorizationUrl: 'https://auth.example.com/authorize', scopes: { 'books:write': 'Modify books' } } },
})
.addCookieAuth('sid')
.addGlobalResponse({ status: 500, description: 'Internal server error' })
.addGlobalParameters({ name: 'tenantId', in: 'header', required: false, schema: { type: 'string' } })
.build();
const options: SwaggerDocumentOptions = {
operationIdFactory: (_controllerKey: string, methodKey: string) => methodKey,
};
const documentFactory = () => SwaggerModule.createDocument(app, config, options);
SwaggerModule.setup('docs', app, documentFactory, { jsonDocumentUrl: 'docs/json', ...setupOptions });
return documentFactory;
}
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { writeFileSync } from 'node:fs';
import { OpenApiAppModule, OpenApiFastifyAppModule } from './app.module.js';
import { setupOpenApi } from './openapi.setup.js';
async function bootstrap() {
const fastify = process.env.HTTP_PLATFORM === 'fastify';
const app = fastify
? await NestFactory.create<NestFastifyApplication>(OpenApiFastifyAppModule, new FastifyAdapter())
: await NestFactory.create(OpenApiAppModule);
const documentFactory = setupOpenApi(app);
if (process.env.WRITE_SPEC) {
writeFileSync(process.env.WRITE_SPEC, JSON.stringify(documentFactory(), null, 2));
}
await app.listen(Number(process.env.PORT ?? 3000), '127.0.0.1');
}
await bootstrap();
The file uses top-level await and .js import extensions, as in the current ESM template. In a CommonJS project, drop the
extensions and call bootstrap() normally. @nestjs/swagger itself is a native ES module since version 12, and CommonJS
applications keep importing it through require() thanks to Node’s require(esm) support (hence the Node.js 20.19+ and
22.12+ floor).
Started on either adapter, the application serves three things:
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" localhost:3000/docs
curl -s localhost:3000/docs/json | head -c 100
curl -s localhost:3000/docs-yaml | head -3
200 text/html; charset=utf-8
{"openapi":"3.0.0","paths":{"/books":{"post":{"description":"Creates a book and returns it with its
openapi: 3.0.0
paths:
/books:
The UI lives at the path you passed to setup(); the JSON definition is served at <path>-json unless
jsonDocumentUrl overrides it (here docs/json, so /docs-json answers 404), and the YAML one at <path>-yaml unless
yamlDocumentUrl overrides it. The document generated by the Express application and the one generated by the Fastify
application were compared and differ only by the routes that exist in one but not the other (the upload controller, see
Express and Fastify differences).
Where the document is built and served: lifecycle position
@nestjs/swagger sits beside the request lifecycle (see Request Lifecycle),
not inside it:
-
Build time of the document.
createDocument()needs an application object because it inspects the module graph (controllers, their route metadata and DTO classes). It does not need the application to be listening. When you pass a factory tosetup(), the document is generated on the first request for the UI or a definition and then reused; in a test, a factory that counted its calls was invoked zero times afterinit()and once after three requests. Passing the result ofcreateDocument()directly generates it eagerly, which is also what you want when you write the file at start-up. -
Serving.
setup()registers plain routes directly on the underlying HTTP adapter (Express or Fastify), outside Nest’s router. Guards, interceptors, pipes and exception filters therefore do not run for the UI, the JSON or the YAML routes. Middleware registered withapp.use()beforesetup()does run. Both facts hold on Express and Fastify:
@Injectable()
class DenyAll implements CanActivate {
canActivate(): boolean {
throw new UnauthorizedException();
}
}
@Controller('secret')
class SecretController {
@Get()
secret() {
return 1;
}
}
@Module({ controllers: [SecretController], providers: [{ provide: APP_GUARD, useClass: DenyAll }] })
class GuardedModule {}
// ... inside the test, after creating the application:
app.use((req: any, res: any, next: () => void) => {
if (req.url.startsWith('/protected-docs') && !req.headers['x-docs-pass']) {
res.statusCode = 401;
res.end('docs need a pass');
return;
}
next();
});
SwaggerModule.setup('open-docs', app, () => SwaggerModule.createDocument(app, config));
SwaggerModule.setup('protected-docs', app, () => SwaggerModule.createDocument(app, config));
Source: NestJS: OpenAPI introduction
GET /secret -> 401 (the global guard runs)
GET /open-docs, /open-docs-json -> 200 (no guard on the documentation routes)
GET /protected-docs-json -> 401 (app.use() middleware ran first)
GET /protected-docs-json + header -> 200
Two consequences follow. A global APP_GUARD does not protect your documentation: if the spec reveals internal routes,
protect it with middleware, a reverse proxy, or by not mounting it (ui: false and raw: false, see
SwaggerModule.setup and its options) outside development. And because the routes are outside Nest, they do not get the global prefix
unless you ask for it (Global prefix).
Order also matters for versioning. If you call createDocument() eagerly, call app.enableVersioning() first; otherwise
the generated paths lack the version prefix. A factory is immune because the document is built later:
@Controller({ path: 'v', version: '1' }) class V1 { @Get() one() { return 1; } }
@Controller({ path: 'v', version: '2' }) class V2 { @Get() two() { return 2; } }
const eager = SwaggerModule.createDocument(app, config); // before enableVersioning()
app.enableVersioning({ type: VersioningType.URI });
const late = SwaggerModule.createDocument(app, config);
// Object.keys(eager.paths) -> ["/v"]
// Object.keys(late.paths) -> ["/v1/v", "/v2/v"]
The versioning feature itself is covered in Versioning & Global Prefix.
DocumentBuilder
DocumentBuilder assembles the base document: everything except the paths, which createDocument() adds. Every method
returns the builder, and build() returns the object.
| Method | What it sets |
|---|---|
|
The |
|
The remaining |
|
The |
|
An entry in |
|
The document-level |
|
A tag defined up front. The optional fourth argument carries |
|
A document-level |
|
A named security scheme in |
|
Ready-made schemes registered under the names |
|
A document-wide security requirement (applies to every operation). |
|
A response added to every operation, typically |
|
A parameter added to every operation, for example a tenant header. |
Source: NestJS: OpenAPI introduction, "Bootstrap", NestJS: OpenAPI, "Other features" and NestJS: OpenAPI, "Security"
The setup shown earlier uses most of these. The resulting document, for example, contains these security schemes:
{
"bearer": { "scheme": "bearer", "bearerFormat": "JWT", "type": "http" },
"api-key": { "type": "apiKey", "in": "header", "name": "X-Api-Key" },
"basic": { "type": "http", "scheme": "basic" },
"oauth2": {
"type": "oauth2",
"flows": {
"implicit": {
"authorizationUrl": "https://auth.example.com/authorize",
"scopes": { "books:write": "Modify books" }
}
}
},
"cookie": { "type": "apiKey", "in": "cookie", "name": "sid" }
}
A global parameter needs an explicit schema: without one the generated parameter has an empty schema ({}), which Swagger
UI shows as an untyped field.
SwaggerModule.createDocument and its options
SwaggerModule.createDocument(app, config, options?) takes the application, the base document and an optional
SwaggerDocumentOptions object, and returns the OpenAPIObject. All options, with their
effect on the document:
| Option | Default | Effect |
|---|---|---|
|
every module |
Restricts the document to the controllers of the listed modules. |
|
none |
Classes to inspect and add to |
|
|
Leaves the global prefix out of the generated paths (see Global prefix). |
|
|
With |
|
|
|
|
|
Names the entries of the |
|
|
Tags operations with the controller name without the |
|
|
Documents only handlers decorated with |
|
|
Omits |
|
|
Truncates |
|
native conversion |
Converts Standard Schema instances into OpenAPI schemas (see Standard Schema DTOs (Zod, Valibot)). |
Two of these are worth a short example. operationIdFactory replaces the verbose default, and excludeDynamicDefaults
together with exampleMaxDepth keeps generated files stable and small. The test applied both and compared the output:
class Dyn {
@ApiProperty({ default: new Date(0) }) when!: Date;
@ApiProperty({ example: { a: { b: { c: { d: 1 } } } } }) deep!: object;
}
const plain = SwaggerModule.createDocument(app, config);
const stable = SwaggerModule.createDocument(app, config, { excludeDynamicDefaults: true, exampleMaxDepth: 2 });
{ "when": { "format": "date-time", "type": "string", "default": "1970-01-01T00:00:00.000Z" },
"deep": { "type": "object", "example": { "a": { "b": { "c": { "d": 1 } } } } } }
{ "when": { "format": "date-time", "type": "string" },
"deep": { "type": "object", "example": { "a": { "b": {} } } } }
SwaggerModule.setup and its options
SwaggerModule.setup(path, app, documentOrFactory, options?) mounts the UI at path and the definitions beside it. The
fourth argument is a SwaggerCustomOptions object:
| Option | Default | Effect |
|---|---|---|
|
|
Prefixes the Swagger routes (UI, JSON, YAML) with the global prefix. |
|
|
|
|
|
|
|
|
Deprecated: use |
|
none |
URL of the API definition that Swagger UI loads. |
|
|
Paths of the JSON and YAML definitions. |
|
none |
|
|
|
Shows the definition selector (dropdown) in the Swagger UI header. |
|
none |
Passed to Swagger UI: |
|
none |
Inline CSS, or URL(s) of stylesheets, for the UI page. |
|
none |
URL(s) of scripts, or inline scripts, for the UI page. |
|
none |
Favicon and page title. |
|
bundled assets |
Folder with static Swagger UI assets, such as |
|
none |
Deprecated: these have no effect. |
ui and raw are independent. Disabling the UI does not disable the definitions, and vice versa:
SwaggerModule.setup('api', app, () => SwaggerModule.createDocument(app, config), {
ui: false, // Swagger UI is disabled
raw: ['json'], // only the JSON definition is served
});
GET /api -> 404
GET /api-json -> 200
GET /api-yaml -> 404
With raw: false and the UI enabled, /api answered 200 (the page loads its definition inline) and /api-json answered
404. The same four requests behaved identically on Express and Fastify.
patchDocumentOnRequest, customSiteTitle and swaggerOptions can be combined:
SwaggerModule.setup('api', app, () => SwaggerModule.createDocument(app, config), {
customSiteTitle: 'Bookshelf docs',
explorer: true,
swaggerOptions: { persistAuthorization: true, docExpansion: 'none' },
patchDocumentOnRequest: (req: any, _res, doc) => ({
...doc,
info: { ...doc.info, description: `served for ${req.headers.host ? 'a host' : 'nobody'}` },
}),
});
The served JSON contained the patched info.description, and the UI page title was Bookshelf docs.
Swagger UI is served from your own origin with a script swagger-ui-init.js that embeds the document. It works with the
default Content Security Policy of Nest’s built-in security headers; custom scripts, plain-HTTP deployments outside local
development and Fastify’s own @fastify/helmet need the adjustments described in
Security Headers, CORS & CSRF.
Describing models: types and parameters
SwaggerModule finds every @Body(), @Query(), @Param() and @Headers() parameter of every route and builds a model
from its class using reflection. TypeScript’s reflection is shallow, though: it records that a property is a string or an
Array, but not which properties a class has unless they are decorated, whether a property is optional, what an array
contains, or which enum a value belongs to. Everything the reflection cannot see is supplied with @ApiProperty(), or
automatically by the CLI plugin (The CLI plugin).
A DTO without any decorator and without the plugin produces an empty schema, which is the most common first surprise:
class Bare {
title!: string;
}
// components.schemas -> { "Bare": { "type": "object", "properties": {} } }
@ApiProperty and @ApiPropertyOptional
@ApiProperty() registers a property and lets you set any Schema Object keyword. @ApiPropertyOptional() is the
shorthand for @ApiProperty({ required: false }). The following DTO combines the options that matter most. It is a
single class, and the output shown after it is the generated schema:
import { ApiProperty, ApiPropertyOptional, ApiHideProperty, ApiSchema } from '@nestjs/swagger';
import { Genre } from './genre.js';
@ApiSchema({ name: 'CreateBookRequest', description: 'Payload to add a book to the catalogue' })
export class CreateBookDto {
@ApiProperty({ description: 'Title as printed on the cover', minLength: 1, maxLength: 200, example: 'The Pragmatic Programmer' })
title: string;
@ApiProperty({ description: 'Pages', minimum: 1, default: 100, type: Number })
pages: number;
@ApiProperty({ type: [String], description: 'Author names' })
authors: string[];
@ApiProperty({ enum: Genre, enumName: 'Genre' })
genre: Genre;
@ApiProperty({ enum: Genre, enumName: 'Genre', isArray: true, description: 'Secondary genres' })
tags: Genre[];
@ApiPropertyOptional({ examples: { short: { value: '978-0135957059' }, long: { value: '9780135957059' } } })
isbn?: string;
@ApiProperty({
type: 'array',
items: { type: 'array', items: { type: 'number' } },
description: 'Pairs of [page, line] bookmarks',
})
bookmarks: number[][];
@ApiProperty({
type: 'object',
properties: { name: { type: 'string', example: 'Error' }, status: { type: 'number', example: 400 } },
required: ['name', 'status'],
})
rawDefinition: Record<string, any>;
@ApiHideProperty()
internalNote?: string;
}
{
"type": "object",
"description": "Payload to add a book to the catalogue",
"properties": {
"title": { "type": "string", "description": "Title as printed on the cover", "minLength": 1, "maxLength": 200, "example": "The Pragmatic Programmer" },
"pages": { "type": "number", "description": "Pages", "minimum": 1, "default": 100 },
"authors": { "description": "Author names", "type": "array", "items": { "type": "string" } },
"genre": { "allOf": [ { "$ref": "#/components/schemas/Genre" } ] },
"tags": { "type": "array", "description": "Secondary genres", "items": { "$ref": "#/components/schemas/Genre" } },
"isbn": { "type": "string", "examples": { "short": { "value": "978-0135957059" }, "long": { "value": "9780135957059" } } },
"bookmarks": { "type": "array", "items": { "type": "array", "items": { "type": "number" } }, "description": "Pairs of [page, line] bookmarks" },
"rawDefinition": { "type": "object", "properties": { "name": { "type": "string", "example": "Error" }, "status": { "type": "number", "example": 400 } }, "required": [ "name", "status" ] }
},
"required": [ "title", "pages", "authors", "genre", "tags", "bookmarks" ]
}
Properties are required unless marked otherwise (isbn is the only optional one, and internalNote is hidden by
@ApiHideProperty()). The options of @ApiProperty() map onto the keywords of the OpenAPI Schema Object:
| Option | Meaning |
|---|---|
|
Documentation values. |
|
Overrides the default of |
|
The type, as a class, |
|
Marks the property as an array of |
|
The allowed values (an array or a TypeScript enum), and the name under which the enum becomes a reusable schema. |
|
|
|
Validation keywords, documentation only. |
|
The value may be |
|
Direction and lifecycle flags: |
|
For maps ( |
|
Composition (see oneOf, anyOf and allOf). |
|
Per-property override of the document option of the same name. |
These keywords produced the expected output in a run (a nullable string, a deprecated property with its description, a UUID format, a pattern, an array with item limits, a write-only property and a map):
class Opts {
@ApiProperty({ nullable: true, type: String }) middleName!: string | null;
@ApiProperty({ deprecated: true, description: 'use title' }) name!: string;
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ pattern: '^[A-Z]{2}\\d{4}$' }) code!: string;
@ApiProperty({ type: [Number], minItems: 1, maxItems: 3, uniqueItems: true }) nums!: number[];
@ApiProperty({ writeOnly: true }) secretWord!: string;
@ApiProperty({ type: 'object', additionalProperties: { type: 'number' } }) counts!: Record<string, number>;
}
{
"middleName": { "type": "string", "nullable": true },
"name": { "type": "string", "deprecated": true, "description": "use title" },
"id": { "type": "string", "format": "uuid" },
"code": { "type": "string", "pattern": "^[A-Z]{2}\\d{4}$" },
"nums": { "minItems": 1, "maxItems": 3, "uniqueItems": true, "type": "array", "items": { "type": "number" } },
"secretWord": { "type": "string", "writeOnly": true },
"counts": { "type": "object", "additionalProperties": { "type": "number" } }
}
Arrays
When the property is an array, indicate the item type: either type: [String] or type: String, isArray: true. Without
it, the reflection only knows Array, and the generated items default to string, whatever the real element type is:
class Arr {
@ApiProperty() names!: string[]; // happens to be right: items default to string
@ApiProperty({ type: [String] }) ok!: string[];
@ApiProperty({ type: String, isArray: true }) ok2!: string[];
}
Source: NestJS: OpenAPI, "Arrays"
The same limitation applies to parameters. A handler typed @Body() items: CreateBookDto[] was documented as an array of
string, so the body of a bulk endpoint needs @ApiBody({ type: [CreateBookDto] }) (reflection cannot see generics or
array element types). The CLI plugin removes this boilerplate (The CLI plugin).
@Post('bulk')
@ApiBody({ type: [CreateBookDto] })
@ApiCreatedResponse({ type: [BookDto] })
createBulk(@Body() dtos: CreateBookDto[]): BookDto[] {
return dtos.map((d, i) => ({ id: i + 1, title: d.title, edition: { narrator: 'n/a', minutes: 0 }, allEditions: [] }));
}
Enums and enumName
Give @ApiProperty() the TypeScript enum (or an array of values) through enum. By default the values are inlined in each
property that uses the enum, which is exactly what makes client generators emit one duplicate enum per property:
export enum Genre {
Fiction = 'fiction',
Science = 'science',
History = 'history',
}
class A { @ApiProperty({ enum: Genre }) genre!: Genre }
class B { @ApiProperty({ enum: Genre }) genre!: Genre }
// A.genre and B.genre each carry { "type": "string", "enum": ["fiction", "science", "history"] }
Source: NestJS: OpenAPI, "Enums"
Passing enumName turns the enum into one shared schema that every usage references, so a generated client gets a single
Genre type:
@ApiProperty({ enum: Genre, enumName: 'Genre' })
genre: Genre;
Source: NestJS: OpenAPI, "Enums schema"
"Genre": { "type": "string", "enum": ["fiction", "science", "history"] }
@ApiQuery() and @ApiParam() accept the same pair, and isArray: true turns a query parameter into a multi-select in
Swagger UI. The array form produces { "type": "array", "items": { "$ref": "#/components/schemas/Genre" } }:
@Get()
@ApiQuery({ name: 'genre', enum: Genre, enumName: 'Genre', isArray: true, required: false })
find(@Query('genre') genre?: Genre[]) {
return genre;
}
Source: NestJS: OpenAPI, "Enums"
With the CLI plugin, autoFillEnumName: true fills enumName for you: a MemberRole[] property was documented with
items: { $ref: '#/components/schemas/MemberRole' } instead of an inlined list.
Property value examples
A single example or an examples object of named { value } entries documents realistic values (both appear in the
CreateBookDto above: the title has example, isbn has two named examples). Keep examples short. A deeply nested one
can be truncated with exampleMaxDepth as shown earlier.
Generics, extra models and getSchemaPath
TypeScript erases generics, so PaginatedDto<TData> cannot be described by reflection. The recipe is: decorate the fixed
properties, leave the generic one undecorated, and describe the concrete combination with a raw schema in the response.
The DTO has to be registered with @ApiExtraModels() because no controller references it directly, and getSchemaPath()
returns the $ref of a registered model:
import { applyDecorators, Type } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, ApiProperty, getSchemaPath } from '@nestjs/swagger';
export class PaginatedDto<TData> {
@ApiProperty() total: number;
@ApiProperty() limit: number;
@ApiProperty() offset: number;
// not decorated: the item type is supplied by ApiPaginatedResponse()
results: TData[];
}
export const ApiPaginatedResponse = <TModel extends Type<unknown>>(model: TModel) =>
applyDecorators(
ApiExtraModels(PaginatedDto, model),
ApiOkResponse({
schema: {
title: `PaginatedResponseOf${model.name}`,
allOf: [
{ $ref: getSchemaPath(PaginatedDto) },
{ properties: { results: { type: 'array', items: { $ref: getSchemaPath(model) } } } },
],
},
}),
);
The decorator is built with applyDecorators() from @nestjs/common (see
Custom Decorators), so it bundles the extra-model registration with the response
and can be reused on any endpoint:
@Get()
@ApiPaginatedResponse(BookSummaryDto)
@ApiQuery({ name: 'genre', enum: Genre, enumName: 'Genre', required: false })
list(@Query('genre') genre?: Genre): PaginatedDto<BookSummaryDto> {
void genre;
return { total: 0, limit: 10, offset: 0, results: [] };
}
The operation’s 200 response in the document is:
{
"title": "PaginatedResponseOfBookSummaryDto",
"allOf": [
{ "$ref": "#/components/schemas/PaginatedDto" },
{ "properties": { "results": { "type": "array", "items": { "$ref": "#/components/schemas/BookSummaryDto" } } } }
]
}
The title is the detail that matters for generated clients. Without it, a generator sees an anonymous
allOf and emits an inline object type; with it, the client method returns PaginatedResponseOfBookSummaryDto.
@ApiExtraModels() is only needed once per model, and it can sit on a controller, a method or a model class. The same
registration is available as the extraModels document option. Forgetting it is silent: a model that is referenced only
through getSchemaPath() and never registered leaves a dangling $ref: the document contains only the owning
schema (["Owner"]) while the property points at schemas that do not exist.
oneOf, anyOf and allOf
Use the composition keywords to describe alternatives. Both classes must be registered as extra models:
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger';
export class PrintEdition {
@ApiProperty() isbn: string;
@ApiProperty() weightGrams: number;
}
export class AudioEdition {
@ApiProperty() narrator: string;
@ApiProperty() minutes: number;
}
export type Edition = PrintEdition | AudioEdition;
@ApiExtraModels(PrintEdition, AudioEdition)
export class BookDto {
@ApiProperty({ readOnly: true })
id: number;
@ApiProperty()
title: string;
@ApiProperty({
oneOf: [{ $ref: getSchemaPath(PrintEdition) }, { $ref: getSchemaPath(AudioEdition) }],
})
edition: Edition;
@ApiProperty({
type: 'array',
items: { oneOf: [{ $ref: getSchemaPath(PrintEdition) }, { $ref: getSchemaPath(AudioEdition) }] },
})
allEditions: Edition[];
}
{
"edition": { "oneOf": [ { "$ref": "#/components/schemas/PrintEdition" }, { "$ref": "#/components/schemas/AudioEdition" } ] },
"allEditions": {
"type": "array",
"items": { "oneOf": [ { "$ref": "#/components/schemas/PrintEdition" }, { "$ref": "#/components/schemas/AudioEdition" } ] }
}
}
A polymorphic array is the case where the raw definition is unavoidable: type: 'array' with an items that carries the
oneOf. The anyOf and allOf keywords are used the same way (allOf is also how a response combines a base schema
with extra properties, as in the paginated example).
Circular references
When two classes reference each other, give @ApiProperty() a lazy type function so the class is looked up when the
document is generated, not when the decorator runs. A class that refers to itself needs nothing else:
import { ApiProperty } from '@nestjs/swagger';
export class CategoryDto {
@ApiProperty()
name: string;
@ApiProperty({ type: () => CategoryDto, required: false })
parent?: CategoryDto;
@ApiProperty({ type: () => [CategoryDto] })
children: CategoryDto[];
}
When the two classes live in different files, a second problem appears that has nothing to do with Swagger: with
emitDecoratorMetadata, TypeScript emits a runtime reference to the property’s class in the design:type metadata. In ESM
that reference is evaluated while the other module is still being initialised. Compiling this pair with tsc and importing
it in Node.js failed:
// shelf.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { VolumeDto } from './volume.dto.js';
export class ShelfDto {
@ApiProperty({ type: () => VolumeDto })
volume: VolumeDto;
}
// volume.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { ShelfDto } from './shelf.dto.js';
export class VolumeDto {
@ApiProperty({ type: () => ShelfDto })
shelf: ShelfDto;
}
Cannot access 'ShelfDto' before initialization
The remedy is a wrapper type that is erased at compile time, so no runtime reference is emitted. Arrays are not affected
(their design:type is just Array):
// relation.ts
// Wrapping a property type in Relation<T> keeps the compiler from emitting a runtime
// reference to T in design:type metadata, which would hit a circular import in ESM.
export type Relation<T> = T;
// author.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import type { Relation } from './relation.js';
import { BookSummaryDto } from './book-summary.dto.js';
export class AuthorDto {
@ApiProperty()
name: string;
@ApiProperty({ type: () => [BookSummaryDto] })
books: Relation<BookSummaryDto[]>;
}
// book-summary.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import type { Relation } from './relation.js';
import { AuthorDto } from './author.dto.js';
export class BookSummaryDto {
@ApiProperty()
title: string;
@ApiProperty({ type: () => AuthorDto })
author: Relation<AuthorDto>;
}
With the wrapper in place, the pair imported cleanly, and the document contained AuthorDto.books and
BookSummaryDto.author as $ref`s to each other. The same wrapper applies to TypeORM-style entity relations (`Relation<T>
exported by typeorm) and to circular constructor injection (see
ModuleRef, Circular Dependencies & Lazy Loading).
In a Vitest run the failure did not reproduce, so a test suite alone does not prove that the compiled application starts.
Schema name and description with @ApiSchema
The schema name defaults to the class name. @ApiSchema() renames it and can add a description, which is how an internal
class name (CreateBookDto) becomes a public contract name (CreateBookRequest). The CreateBookDto above shows it:
@ApiSchema({ name: 'CreateBookRequest', description: 'Payload to add a book to the catalogue' })
export class CreateBookDto {}
The class was registered as CreateBookRequest in components.schemas, with the description, and the operation’s request
body refers to that name.
Raw definitions
For shapes that reflection cannot express (nested arrays, matrices, free-form objects) write the schema by hand. It can
be set on a property (see bookmarks and rawDefinition in CreateBookDto) or on a whole body or response with the
schema option:
@Post('coords')
@ApiBody({ schema: { type: 'array', items: { type: 'array', items: { type: 'number' } } } })
coords(@Body() c: number[][]) {
return c;
}
{ "required": true, "content": { "application/json": { "schema": { "type": "array", "items": { "type": "array", "items": { "type": "number" } } } } } }
Standard Schema DTOs (Zod, Valibot)
NestJS 12 route parameter decorators accept a Standard Schema through their schema option
(see Pipes & Validation for the validation side, and
Controllers & Routing). @nestjs/swagger reads these schemas and turns
them into request bodies and parameters, so a Zod or Valibot schema is both the validator and the documentation, with no
@ApiProperty() at all.
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { z } from 'zod';
export const createReviewSchema = z.object({
bookId: z.number().int().positive(),
stars: z.number().int().min(1).max(5),
text: z.string().max(500).optional(),
});
export type CreateReview = z.infer<typeof createReviewSchema>;
export const reviewQuerySchema = z.object({ minStars: z.coerce.number().int().min(1).max(5).default(1) });
@ApiTags('reviews')
@Controller('reviews')
export class ReviewsController {
@Post()
create(@Body({ schema: createReviewSchema }) review: CreateReview) {
return review;
}
@Get()
list(@Query({ schema: reviewQuerySchema }) query: z.infer<typeof reviewQuerySchema>) {
return query;
}
}
With Zod 4 (checked with 4.6.5) no configuration was needed: Zod implements the Standard JSON Schema extension (schemas expose
~standard.jsonSchema), and Nest converts such schemas itself, requesting the openapi-3.0 target. The generated
operation:
{
"required": true,
"content": { "application/json": { "schema": {
"type": "object",
"properties": {
"bookId": { "type": "integer", "minimum": 0, "exclusiveMinimum": true, "maximum": 9007199254740991 },
"stars": { "type": "integer", "minimum": 1, "maximum": 5 },
"text": { "type": "string", "maxLength": 500 }
},
"required": ["bookId", "stars"]
} } }
}
and the query schema produced a query parameter named minStars, not required, with default: 1 and the 1 to 5
range. Note what the schema describes: the input shape (schemaType: 'input' in the converter below), which is what
clients send; for a schema that transforms values, the output shape is what the handler receives.
A library that does not implement that extension needs a converter, and silence is the failure mode. A Valibot schema passed without one was documented as a reference to a schema that does not exist:
{ "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Object" } } } }
The fix is the standardSchemaConverter document option. It receives the raw schema and the schemaType ('input' or
'output') and returns { schema, components? }, or undefined to fall back to Nest’s native conversion. Branching on
the vendor (~standard.vendor) lets one converter serve several libraries; always narrow by vendor before calling a
library-specific function, because passing a Valibot schema to the Zod converter (or the reverse) throws at document
generation time. This example uses zod-openapi (which can hoist reusable definitions, so it returns components) and
@valibot/to-json-schema (a single self-contained schema), both installed as development dependencies:
npm i --save-dev zod-openapi @valibot/to-json-schema
import type { SwaggerDocumentOptions } from '@nestjs/swagger';
import { toJsonSchema } from '@valibot/to-json-schema';
import { createSchema } from 'zod-openapi';
function hasVendor(schema: unknown, vendor: string) {
return (
!!schema &&
typeof schema === 'object' &&
(schema as { '~standard'?: { vendor?: string } })['~standard']?.vendor === vendor
);
}
export const documentOptions: SwaggerDocumentOptions = {
standardSchemaConverter: (schema, { schemaType }) => {
if (hasVendor(schema, 'zod')) {
const converted = createSchema(schema as never, { io: schemaType, openapiVersion: '3.0.0' });
return { schema: converted.schema, components: converted.components };
}
if (hasVendor(schema, 'valibot')) {
return { schema: toJsonSchema(schema as never, { target: 'openapi-3.0', typeMode: schemaType }) };
}
// Not handled here: let Nest fall back to its native conversion
return undefined;
},
};
Source: NestJS: OpenAPI introduction, "Supplying a converter" and NestJS: OpenAPI introduction, "Supporting several libraries at once"
import { Body, Controller, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import * as v from 'valibot';
export const createShelfSchema = v.object({
name: v.pipe(v.string(), v.minLength(1)),
capacity: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 20),
});
@ApiTags('shelves')
@Controller('shelves')
export class ShelvesController {
@Post()
create(@Body({ schema: createShelfSchema }) shelf: v.InferOutput<typeof createShelfSchema>) {
return shelf;
}
}
Passed as the third argument of createDocument(app, config, documentOptions), the converter documented both
controllers: the Valibot body became { "type": "object", "properties": { "name": { "type": "string", "minLength": 1 }, "capacity": { "type": "integer", "minimum": 1, "default": 20 } }, "required": ["name"] }
and the Zod body matched the output shown above. A hand-written converter is also possible (the Zod-only variant that calls
z.toJSONSchema(schema, { target: 'openapi-3.0', io: schemaType }) and returns { schema } produced the same schema for
the review body). The converter is called once per documented position (two Zod schemas produce two zod:input
calls).
Describing operations
OpenAPI calls the endpoints paths and the HTTP methods applied to them operations. The decorators below describe an operation; they never change what the handler does.
Tags and operation summaries
@ApiTags() groups operations under tag headings in Swagger UI; @ApiOperation() adds a summary and a description.
Without @ApiTags(), autoTagControllers tags the operation with the controller name.
@ApiTags('books')
@Controller('books')
export class BooksController {
@Post()
@ApiOperation({ summary: 'Add a book', description: 'Creates a book and returns it with its generated id.' })
create(@Body() dto: CreateBookDto): BookDto {
return { id: 1, title: dto.title, edition: { isbn: dto.isbn ?? 'n/a', weightGrams: 400 }, allEditions: [] };
}
}
Source: NestJS: OpenAPI, "Tags"
OpenAPI 3.2 lets tags form a hierarchy and carry a presentation hint. These fields can only be defined with
DocumentBuilder.addTag() (the @ApiTags() decorator ignores them and logs a warning), and you must also call
setOpenAPIVersion('3.2.0'):
const config = new DocumentBuilder()
.setOpenAPIVersion('3.2.0')
.addTag('Animals', 'Everything about animals', undefined, { kind: 'nav' })
.addTag('Cats', 'Cat operations', undefined, { parent: 'Animals' })
.build();
Source: NestJS: OpenAPI, "Tags"
The document came out with openapi: "3.2.0" and tags containing { "name": "Animals", "description": "Everything about
animals", "kind": "nav" } and { "name": "Cats", "description": "Cat ops", "parent": "Animals" }. Without
setOpenAPIVersion(), the openapi field stayed at 3.0.0 while the tag still carried kind, which strict validators
reject.
Parameters and headers
Path, query and header parameters are discovered from @Param(), @Query() and @Headers(). @ApiParam() and
@ApiQuery() refine or add them (description, type, example, enum, required), and @ApiHeader() documents a header the
handler reads without declaring it as a parameter; @ApiHeaders() takes several at once. Both can be applied on a method or
a whole controller:
@ApiTags('books')
@ApiHeader({ name: 'X-Request-Id', description: 'Correlation id echoed in logs', required: false })
@Controller('books')
export class BooksController {
@Get(':id')
@ApiParam({ name: 'id', description: 'Book id', type: Number, example: 42 })
@ApiProduces('application/json')
findOne(@Param('id', ParseIntPipe) id: number): BookDto {
return { id, title: 'Sample', edition: { isbn: 'n/a', weightGrams: 1 }, allEditions: [] };
}
}
Source: NestJS: OpenAPI, "Headers"
The X-Request-Id header was added as a parameter to every operation of the controller, and the id parameter came out as
{ "name": "id", "required": true, "in": "path", "description": "Book id", "schema": { "example": 42, "type": "number" } }.
@ApiProduces() and @ApiConsumes() set the media types.
@ApiHeaders([{ name: 'X-Tenant', required: true }, { name: 'X-Trace', required: false }])
@Controller('loans')
export class LoansController {}
Source: NestJS: OpenAPI, "Decorators"
Responses
@ApiResponse({ status, description, type }) documents one response. Nest provides one shorthand per common status code,
all of them built on @ApiResponse() and applicable at the same levels:
| Success and redirect | Client and server errors |
|---|---|
|
|
Source: NestJS: OpenAPI, "Responses"
Pick the shorthand that matches what Nest actually returns. A POST handler answers 201, so it takes
@ApiCreatedResponse(). Using @ApiOkResponse() on a POST documents 200 while the application answers 201; a test
that requested both found ["200", "500"] in the document (the 500 is the global response) and 201 at runtime, which
breaks every generated client that checks the documented status.
@Post()
@ApiOperation({ summary: 'Add a book', description: 'Creates a book and returns it with its generated id.' })
@ApiCreatedResponse({ description: 'The book has been created.', type: BookDto })
@ApiBadRequestResponse({ description: 'The payload is invalid.' })
@ApiForbiddenResponse({ description: 'Forbidden.' })
create(@Body() dto: CreateBookDto): BookDto {
return { id: 1, title: dto.title, edition: { isbn: dto.isbn ?? 'n/a', weightGrams: 400 }, allEditions: [] };
}
@Get(':id')
@ApiOkResponse({ type: BookDto })
@ApiNotFoundResponse({ description: 'No such book.' })
@ApiDefaultResponse({ description: 'Unexpected error.' })
findOne(@Param('id', ParseIntPipe) id: number): BookDto {
return { id, title: 'Sample', edition: { isbn: 'n/a', weightGrams: 1 }, allEditions: [] };
}
@Delete(':id')
@HttpCode(204)
@ApiNoContentResponse({ description: 'Deleted.' })
@ApiResponse({ status: 409, description: 'The book is currently borrowed.' })
remove(@Param('id', ParseIntPipe) id: number): void {
void id;
}
Source: NestJS: OpenAPI, "Responses"
The type of a response (or an array form such as type: [BookDto]) refers to a class whose properties carry
@ApiProperty(); the same applies to the Cat model in the official example. A response model is only as complete as its
decorators, and an undecorated class again produces { "type": "object", "properties": {} }.
For responses every operation shares, define them once on the builder instead of repeating them:
const config = new DocumentBuilder()
.addGlobalResponse({ status: 500, description: 'Internal server error' })
// other configurations
.build();
Source: NestJS: OpenAPI, "Responses"
The 500 entry appeared in every operation of the document, next to the ones declared by the decorators.
@ApiExcludeEndpoint() and @ApiExcludeController() remove a handler or a controller from the document, and
@ApiExtension() attaches vendor data:
@Get(':id')
@ApiExtension('x-owner', { team: 'catalogue' })
findOne(@Param('id', ParseIntPipe) id: number): BookDto { /* ... */ }
@Get('internal/health')
@ApiExcludeEndpoint()
health() {
return { ok: true };
}
Source: NestJS: OpenAPI, "Extensions"
The extension name must start with x-, otherwise the decorator throws. The extension appeared on the operation as
"x-owner": { "team": "catalogue" }, and the health route was absent from paths.
File uploads: @ApiConsumes and @ApiBody
Swagger UI cannot know that a handler reads a multipart form. Declare the media type with @ApiConsumes() and describe
the body with a class whose file property is a binary string:
// upload.dto.ts
import { ApiProperty } from '@nestjs/swagger';
export class FileUploadDto {
@ApiProperty({ type: 'string', format: 'binary' })
file: unknown;
}
export class FilesUploadDto {
@ApiProperty({ type: 'array', items: { type: 'string', format: 'binary' } })
files: unknown[];
}
// uploads-express.controller.ts
import { Controller, Post, UploadedFile, UploadedFiles, UseInterceptors } from '@nestjs/common';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { FilesUploadDto, FileUploadDto } from './upload.dto.js';
@ApiTags('covers')
@Controller('covers')
export class UploadsExpressController {
@Post()
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiBody({ description: 'Cover image', type: FileUploadDto })
upload(@UploadedFile() file: Express.Multer.File) {
return { name: file.originalname, size: file.size };
}
@Post('many')
@UseInterceptors(FilesInterceptor('files'))
@ApiConsumes('multipart/form-data')
@ApiBody({ type: FilesUploadDto })
uploadMany(@UploadedFiles() files: Express.Multer.File[]) {
return { count: files.length };
}
}
Source: NestJS: OpenAPI, "File upload"
A multipart request with one file returned 201 {"name":"cover.png","size":3} and a request with two files returned
201 {"count":2}. The documented request body is:
{ "required": true, "description": "Cover image",
"content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/FileUploadDto" } } } }
On Fastify the documentation decorators are identical; only the interceptor and the file type change (see Express and Fastify differences and File Upload & Streaming).
Callbacks, links and webhooks
Three less common decorators describe relationships between operations. Applied to a LoansController with
OpenAPI 3.1 selected, they produce the document output below:
export class LoanDto {
@ApiResponseProperty({ example: 7 })
id: number;
@ApiProperty()
bookId: number;
}
export class LoanEventDto {
@ApiProperty() loanId: number;
@ApiProperty() returned: boolean;
}
@ApiTags('loans')
@Controller('loans')
export class LoansController {
@Post()
@ApiCreatedResponse({ type: LoanDto })
@ApiCallbacks({
name: 'onReturn',
callbackUrl: '{$request.body#/callbackUrl}',
method: 'post',
requestBody: { type: LoanEventDto },
expectedResponse: { status: 200, description: 'Receiver acknowledged' },
})
create(@Body() dto: LoanDto): LoanDto {
return dto;
}
@Get(':id')
@ApiDefaultGetter(LoanDto, 'id')
@ApiOkResponse({ type: LoanDto })
findOne(@Param('id') id: string): LoanDto {
return { id: Number(id), bookId: 1 };
}
@Get(':id/book')
@ApiLink({ from: LoanDto, fromField: 'id', routeParam: 'id' })
@ApiOkResponse({ type: LoanDto })
book(@Param('id') id: string): LoanDto {
return { id: Number(id), bookId: 1 };
}
@Post('returned')
@ApiWebhook('loan-returned')
@ApiOkResponse({ type: LoanEventDto })
returned(@Body() e: LoanEventDto) {
return e;
}
}
Source: NestJS: OpenAPI, "Decorators"
-
@ApiCallbacks()producedcallbacks.onReturn["{$request.body#/callbackUrl}"].postonPOST /loans, with the request body referring toLoanEventDtoand the expected200response. -
@ApiLink()produced alinksentry on every response whose model isLoanDto:"LoansController_book_from_id": { "operationId": "LoansController_book", "parameters": { "id": "$response.body#/id" } }. The entry name comes fromlinkNameFactory. -
@ApiWebhook('loan-returned')moved the operation frompathsto the top-levelwebhooksobject, which exists in OpenAPI 3.1 and later (setOpenAPIVersion('3.1.0')). -
@ApiResponseProperty()declares a response-only property:LoanDto.idwas documented withreadOnly: trueand itsexample.
Security decorators
Describing authentication takes two steps: define the scheme on the builder, then apply a decorator to the controller or handler that needs it. The decorator only documents the requirement (and makes Swagger UI send the credential); the enforcement is a guard (see Guards and Authentication).
| Decorator | Builder call | Scheme registered as |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Any named scheme, for example |
Source: NestJS: OpenAPI, "Security"
A controller using every flavour, with a bearer guard that enforces the documented requirement on one route:
import { CanActivate, Controller, ExecutionContext, Get, Injectable, UnauthorizedException, UseGuards } from '@nestjs/common';
import {
ApiBasicAuth,
ApiBearerAuth,
ApiCookieAuth,
ApiOAuth2,
ApiOkResponse,
ApiSecurity,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
@Injectable()
export class BearerGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const header = context.switchToHttp().getRequest<{ headers: Record<string, string | undefined> }>().headers.authorization;
if (!header?.startsWith('Bearer ')) throw new UnauthorizedException();
return true;
}
}
@ApiTags('account')
@Controller('account')
export class SecureController {
@Get('me')
@UseGuards(BearerGuard)
@ApiBearerAuth() // refers to the "bearer" scheme added with addBearerAuth()
@ApiUnauthorizedResponse({ description: 'Missing or invalid token.' })
@ApiOkResponse({ description: 'The current user.' })
me() {
return { name: 'reader' };
}
@Get('keys')
@ApiSecurity('api-key')
keys() {
return [];
}
@Get('legacy')
@ApiBasicAuth()
legacy() {
return {};
}
@Get('sso')
@ApiOAuth2(['books:write'], 'oauth2')
sso() {
return {};
}
@Get('session')
@ApiCookieAuth()
session() {
return {};
}
}
Source: NestJS: OpenAPI, "Security"
The generated security requirements, by route:
{
"/account/me": [{ "bearer": [] }],
"/account/keys": [{ "api-key": [] }],
"/account/legacy": [{ "basic": [] }],
"/account/sso": [{ "oauth2": ["books:write"] }],
"/account/session": [{ "cookie": [] }]
}
addSecurityRequirements('bearer') on the builder instead applies one requirement to every operation (the document’s
top-level security was [{ "bearer": [] }]). The decorator’s name must match the scheme’s registration name: a typo
silently documents a requirement that points at nothing. Never put real tokens or keys into addSecurity() examples;
Swagger UI’s Authorize dialog (with persistAuthorization: true to keep the value across reloads) is where a developer
pastes a token.
Mapped types
CRUD APIs need variants of a base DTO: all fields required on create, optional on update. @nestjs/swagger exports four
helpers that build such classes and copy their @ApiProperty() metadata, so the derived DTO is documented too:
import { ApiProperty, IntersectionType, OmitType, PartialType, PickType } from '@nestjs/swagger';
export class NewBookDto {
@ApiProperty() title: string;
@ApiProperty() pages: number;
@ApiProperty() isbn: string;
}
export class Shelving {
@ApiProperty() shelf: string;
}
export class UpdateBookDto extends PartialType(NewBookDto) {}
export class PagesOnlyDto extends PickType(NewBookDto, ['pages'] as const) {}
export class WithoutTitleDto extends OmitType(NewBookDto, ['title'] as const) {}
export class ShelvedBookDto extends IntersectionType(NewBookDto, Shelving) {}
export class PatchWithoutIsbnDto extends PartialType(OmitType(NewBookDto, ['isbn'] as const)) {}
Source: NestJS: OpenAPI, "Mapped types"
The five derived schemas in the document:
| Class | Schema properties (required in parentheses) |
|---|---|
|
|
|
|
|
|
|
|
|
|
The helpers are composable, as PatchWithoutIsbnDto shows. Import them from @nestjs/swagger, not from
@nestjs/mapped-types. Using the core helper in a project that documents with Swagger loses the Swagger metadata: a
class extending PartialType from @nestjs/mapped-types produced { "type": "object", "properties": {} }. The GraphQL
variants are covered with GraphQL: Code-first & Schema-first,
and the validation side with Pipes & Validation.
Decorator reference
All OpenAPI decorators carry an Api prefix to distinguish them from the core ones. The table lists every exported
decorator and the level at which it can be applied. The shorthand response decorators can be applied at the same levels as
@ApiResponse().
| Decorator | Level | Purpose |
|---|---|---|
|
Method / Controller |
Declare which security scheme an operation uses. |
|
Method |
Describes the request body (type, schema, description); needed for arrays and uploads. |
|
Method / Controller |
Request and response media types. |
|
Controller |
Leaves a whole controller out of the document. |
|
Method |
Leaves one handler out. |
|
Method |
Opts a handler in when |
|
Method |
Marks the handler used for links to a model. |
|
Method |
Adds an OpenAPI link from responses of a model to this operation. |
|
Method / Controller |
Adds an |
|
Method / Controller / Model |
Registers schemas that no route references directly. |
|
Method / Controller |
Document request headers. |
|
Model |
Hides a property from the schema. |
|
Method |
Summary, description and other operation fields. |
|
Method / Controller |
Describe or add path and query parameters. |
|
Model |
Renames a schema and adds a description. |
|
Model |
Declare a property; the second makes it optional. |
|
Method / Controller |
Document a response. |
|
Model |
Declares a response-only ( |
|
Method / Controller |
Group operations under tags. |
|
Method / Controller |
Document callbacks the API invokes. |
|
Method |
Document an operation as a webhook (OpenAPI 3.1+). |
Source: NestJS: OpenAPI, "Decorators"
The CLI plugin
Reflection cannot see which properties a class has or whether they are optional, which is why every DTO property needs an
@ApiProperty(). The CLI plugin removes that boilerplate by reading the TypeScript source at compile time (it works on the
Abstract Syntax Tree) and adding the missing decorators. It is opt-in, and it never replaces runtime validation:
class-validator decorators are still what validates the request, while the plugin only turns them into documentation.
What the plugin generates
-
annotates every DTO property with
@ApiProperty()(unless@ApiHideProperty()is used); -
sets
requiredfrom the question mark (name?: stringis optional); -
sets
typeorenumfrom the TypeScript type (arrays included); -
sets
defaultfrom an initialiser; -
copies validation rules from
class-validatordecorators (classValidatorShim, on by default in the option table); -
adds a response decorator with a proper status and
typeto every endpoint; -
generates descriptions and examples from comments (
introspectComments).
For the plugin to analyse a DTO, the file name must end with .dto.ts or .entity.ts (change it with
dtoFileNameSuffix). Controllers are recognised by .controller.ts.
Enable it in nest-cli.json:
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"plugins": [
{
"name": "@nestjs/swagger",
"options": { "introspectComments": true, "classValidatorShim": true }
}
]
}
}
Source: NestJS: OpenAPI, "CLI Plugin"
Delete the dist folder and rebuild whenever you change the plugin options. With that configuration, this DTO has no
@ApiProperty() at all:
import { IsEmail, IsInt, Max, Min, MinLength } from 'class-validator';
import { MemberRole } from './member-role.js';
export class CreateMemberDto {
/**
* E-mail address used to sign in
* @example reader@example.com
*/
@IsEmail()
email: string;
/** Display name shown on reviews */
@MinLength(3)
displayName: string;
/**
* Roles granted to the member
* @example ['reader']
*/
roles: MemberRole[] = [];
/** Star rating the member gave the app */
@IsInt()
@Min(1)
@Max(5)
rating?: number;
isActive?: boolean = true;
}
The controller carries only comments and @Param/@Query parameters:
@Controller('members')
export class MembersController {
/**
* Register a member
*
* @remarks Creates the member and sends a welcome mail.
* @throws {409} A member with this e-mail already exists.
*/
@Post()
create(@Body() dto: CreateMemberDto): CreateMemberDto {
return dto;
}
/**
* List members
*
* @param role Only members with this role
* @param limit Maximum number of results to return
*/
@Get()
findAll(@Query('role') role?: string, @Query('limit') limit?: number) {
return { role, limit };
}
/**
* Find one member
*
* @param id Member id
*/
@Get(':id')
findOne(@Param('id') id: string) {
return { id };
}
}
Built with nest build and turned into a document by the script shown in Generating files and clients from the spec, the schema was:
{
"CreateMemberDto": {
"type": "object",
"properties": {
"email": { "type": "string", "description": "E-mail address used to sign in", "example": "reader@example.com", "format": "email" },
"displayName": { "type": "string", "description": "Display name shown on reviews", "minLength": 3 },
"roles": { "description": "Roles granted to the member", "example": ["reader"], "default": [], "type": "array",
"items": { "type": "string", "enum": ["reader", "editor"] } },
"rating": { "type": "number", "description": "Star rating the member gave the app", "minimum": 1, "maximum": 5 },
"isActive": { "type": "boolean", "default": true }
},
"required": ["email", "displayName", "roles"]
}
}
and the operations received their summaries from the first comment line, POST /members its description from @remarks, its
409 response from @throws {409}, and the query and path parameters their descriptions from @param:
{ "name": "role", "required": false, "in": "query", "description": "Only members with this role", "schema": { "type": "string" } }
@param tags are matched by the variable name, so @Query('order_by') orderBy: string is documented with
@param orderBy …. Explicit @ApiQuery() and @ApiParam() decorators always win. The same DTO built with the plugin
removed produced { "CreateMemberDto": { "type": "object", "properties": {} } }.
Plugin options
| Option | Default | Description |
|---|---|---|
|
|
File name suffixes the plugin treats as DTOs. |
|
|
File name suffixes the plugin treats as controllers. |
|
|
Reuses |
|
|
|
|
|
Property key that receives the comment text on |
|
|
Property key that receives the comment text on |
|
|
Generates descriptions, examples, |
|
|
Disables the automatic |
|
|
Does not copy property initialisers into |
|
|
Sets |
|
detected per file |
Makes the generated code ESM-compatible; when omitted, the plugin resolves each file’s module format from the compiler options and |
A second build with { "introspectComments": false, "classValidatorShim": false, "autoFillEnumName": true } confirmed the
switches: the email property was only { "type": "string" } (no format), no operation had a summary, and the enum was
extracted into a shared MemberRole schema referenced from roles.
SWC and the PluginMetadataGenerator
Plugins are TypeScript compiler transformers, so a builder that does not run tsc does not run them. With the SWC builder
(nest start -b swc), turn on type checking so that the CLI runs the plugins and produces a serialised metadata file:
nest start -b swc --type-check
Source: NestJS: OpenAPI, "SWC builder"
In monorepos, and anywhere the application is compiled without the Nest CLI, generate that file yourself with the
PluginMetadataGenerator, then load it at runtime before creating the document. The generator type-checks the project
first, so it needs a placeholder metadata.ts (export default async () ⇒ ({});) to exist before the first run:
import { PluginMetadataGenerator } from '@nestjs/cli/lib/compiler/plugins/plugin-metadata-generator.js';
import { ReadonlyVisitor } from '@nestjs/swagger/plugin';
const generator = new PluginMetadataGenerator();
generator.generate({
visitors: [new ReadonlyVisitor({ introspectComments: true, pathToSource: import.meta.dirname })],
outputDir: import.meta.dirname,
watch: false,
tsconfigPath: 'tsconfig.build.json',
});
Run it with node src/generate-metadata.ts (Node.js 24 strips the types) or npx ts-node src/generate-metadata.ts.
The generator accepts watch, tsconfigPath, outputDir, visitors, filename (default metadata.ts) and
printDiagnostics. It wrote a metadata.ts whose content, abbreviated, is:
export default async () => {
const t = {
["./member-role.js"]: await import("./member-role.js"),
["./create-member.dto.js"]: await import("./create-member.dto.js")
};
return { "@nestjs/swagger": { "models": [[import("./create-member.dto.js"), { "CreateMemberDto": { email: { required: true, type: () => String, description: "E-mail address used to sign in", example: "reader@example.com", format: "email" }, /* ... */ } }]], "controllers": [[import("./members.controller.js"), { /* ... */ }]] } };
};
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { writeFileSync } from 'node:fs';
import metadata from './metadata.js'; // <-- file written by the PluginMetadataGenerator
import { MembersModule } from './members.module.js';
const app = await NestFactory.create(MembersModule, { logger: false });
await SwaggerModule.loadPluginMetadata(metadata); // <-- here
const document = SwaggerModule.createDocument(app, new DocumentBuilder().setTitle('Members').setVersion('1').build());
writeFileSync(process.argv[2] ?? 'openapi-swc.json', JSON.stringify(document, null, 2));
await app.close();
Source: NestJS: OpenAPI, "SWC builder"
The project was compiled without the plugin (the "no plugin" configuration) and the metadata alone restored the
descriptions, formats, minLength and the optional/required split: the email schema was
{ "type": "string", "description": "E-mail address used to sign in", "example": "reader@example.com", "format": "email" }
and the property list was email, displayName, roles, rating, isActive.
The plugin and your test runner
The plugin is part of the Nest CLI build. A test runner that compiles TypeScript itself does not run it: under Vitest
the same undecorated DTO produces the empty properties: {} schema, so a test that asserts against the
generated document needs either explicit decorators or the metadata mechanism above. For Jest with ts-jest, the
documentation describes registering the plugin as an AST transformer; see
Testing for the runner setup of the current template.
Other features: global prefix, global parameters, multiple specs
The official "Other features" page collects the remaining options. They have all been exercised above; this section gathers the behaviour.
Global prefix
The prefix from app.setGlobalPrefix() is added to the paths in the document but not to the UI routes:
app.setGlobalPrefix('api/v1');
SwaggerModule.setup('docs', app, () => SwaggerModule.createDocument(app, config));
SwaggerModule.setup('docs2', app, () => SwaggerModule.createDocument(app, config, { ignoreGlobalPrefix: true }), {
useGlobalPrefix: true,
});
| Request | Result |
|---|---|
|
200; paths |
|
200; paths |
|
404 |
Express and Fastify behaved identically. ignoreGlobalPrefix is useful when a reverse proxy already adds the prefix, as the
servers entry then carries it. The release notes of NestJS 12.1.0 mention accepting an array of prefixes
(setGlobalPrefix(['api', 'v1'])), but the published 12.1.2 packages still type the argument as a string, so this page
documents the string form; the exclude option was always an array. See
Versioning & Global Prefix.
Global parameters and responses
addGlobalParameters() and addGlobalResponse() on the builder apply to every route (both appear in the setup shown
earlier). They are the right place for a tenant header or the 500 response. The tenant header showed up on all
operations with "schema": { "type": "string" } once the schema was given.
Multiple specifications
To serve different documents on different endpoints (for example one per bounded context), build each with the include
option and mount it with its own setup(). This requires a modular application, because include takes modules. The
explorer dropdown lists several definitions by pointing swaggerOptions.urls at their JSON paths:
const main = new DocumentBuilder().setTitle('Bookshelf').setVersion('1.0').build();
const catalog = SwaggerModule.createDocument(app, new DocumentBuilder().setTitle('Catalogue').setVersion('1.0').build(), {
include: [CatalogModule],
});
const reviews = SwaggerModule.createDocument(app, new DocumentBuilder().setTitle('Reviews').setVersion('1.0').build(), {
include: [ReviewsModule],
});
SwaggerModule.setup('api/catalog', app, catalog, { jsonDocumentUrl: '/api/catalog/swagger.json' });
SwaggerModule.setup('api/reviews', app, reviews, { jsonDocumentUrl: '/api/reviews/swagger.json' });
SwaggerModule.setup('api', app, SwaggerModule.createDocument(app, main), {
explorer: true,
swaggerOptions: {
urls: [
{ name: '1. Catalogue', url: 'api/catalog/swagger.json' },
{ name: '2. Reviews', url: 'api/reviews/swagger.json' },
],
},
jsonDocumentUrl: '/api/swagger.json',
});
Requests on both adapters returned a "Catalogue" document with the catalogue routes, a "Reviews" document with only
/reviews, and the explorer page whose embedded options listed both names. The urls must point at JSON definitions, so
set jsonDocumentUrl on each document.
Generating files and clients from the spec
The document is a plain object, so it can leave the running application. Two jobs are common: write it to a file (for review in pull requests and for tools) and generate typed clients from it.
Writing the spec to a file
The simplest script creates the application, builds the document and writes it. This one was compiled with nest build and
run with Node.js:
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { writeFileSync } from 'node:fs';
import { MembersModule } from './members.module.js';
const app = await NestFactory.create(MembersModule, { logger: false });
const document = SwaggerModule.createDocument(app, new DocumentBuilder().setTitle('Members').setVersion('1').build());
writeFileSync(process.argv[2] ?? 'openapi.json', JSON.stringify(document, null, 2));
await app.close();
node dist/generate-document.js openapi.json
An application whose providers connect to a database at start-up would make this script need a database in CI. The
preview option of NestFactory avoids that: providers and controllers are not instantiated, but the module graph is
resolved, which is all createDocument() needs. A provider whose constructor throws proved it:
import { Injectable, Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { MembersController } from './members.controller.js';
@Injectable()
class NeedsDatabase {
constructor() {
throw new Error('connecting to the database...');
}
}
@Module({ controllers: [MembersController], providers: [NeedsDatabase] })
class OfflineModule {}
const build = (preview: boolean) =>
NestFactory.create(OfflineModule, { logger: false, abortOnError: false, preview });
try {
const app = await build(false);
await app.close();
console.log('without preview: started');
} catch (error) {
console.log('without preview:', (error as Error).message);
}
const app = await build(true);
const document = SwaggerModule.createDocument(app, new DocumentBuilder().setTitle('Offline').setVersion('1').build());
console.log('with preview:', Object.keys(document.paths).join(', '));
without preview: connecting to the database...
with preview: /members, /members/{id}
Note that preview is a general NestFactory option (it is not specific to @nestjs/swagger), so a providers-level side
effect such as a database connection is skipped but so is anything else that runs in constructors. A guard or provider that
the document depends on at generation time (rare) would need the normal start-up.
A typed client
Any OpenAPI generator can consume the file. The example uses openapi-typescript to generate types and openapi-fetch
to get a client whose paths, parameters and bodies are checked by the compiler:
npm i openapi-fetch
npm i --save-dev openapi-typescript
npx openapi-typescript openapi.json -o src/client/schema.d.ts
import createClient from 'openapi-fetch';
import type { paths } from './schema.js';
export const createBooksClient = (baseUrl: string) => createClient<paths>({ baseUrl });
Source: NestJS: OpenAPI introduction
Used against the running application (POST /books returned 201 and the typed body; GET /books/{id} returned 200):
const client = createBooksClient(baseUrl);
const created = await client.POST('/books', {
body: { title: 'Refactoring', pages: 448, authors: ['Martin Fowler'], genre: 'fiction', tags: [], bookmarks: [[1, 2]], rawDefinition: { name: 'x', status: 1 } },
});
const one = await client.GET('/books/{id}', { params: { path: { id: 7 } } });
// @ts-expect-error unknown path is rejected at compile time
const unreachable = () => client.GET('/nope');
Source: NestJS: OpenAPI introduction
The generated types mirror the decorators you wrote. BookDto came out as a readonly id, and the oneOf as a union:
BookDto: {
readonly id: number;
title: string;
edition: components["schemas"]["PrintEdition"] | components["schemas"]["AudioEdition"];
allEditions: (components["schemas"]["PrintEdition"] | components["schemas"]["AudioEdition"])[];
};
A few decorator choices decide how good the generated client is: enumName produces one enum instead of one per property;
title on a generic response (shown in ApiPaginatedResponse) gives the response a name; operationIdFactory returning
the method name yields client functions called createUser, not UsersController_createUser; and
@ApiSchema({ name }) controls the type names. NSwag, OpenAPI Generator and @hey-api/openapi-ts consume the same file.
Regenerating clients from the committed spec in CI, and failing the build when the file changes without being committed,
turns the document into a contract. A contract test can also assert directly on the document:
const document = SwaggerModule.createDocument(app, config);
const ids = Object.values(document.paths).flatMap((item) => Object.values(item).map((op: any) => op.operationId));
expect(new Set(ids).size).toBe(ids.length);
expect(document.paths['/books'].post!.responses).toHaveProperty('201');
expect(document.components!.schemas).toHaveProperty('CreateBookRequest');
const res = await request(app.getHttpServer()).post('/books').send(validBody);
expect(String(res.status)).toBe(Object.keys(document.paths['/books'].post!.responses).find((c) => c.startsWith('2')));
Source: NestJS: OpenAPI introduction
The last assertion compares the documented success status with the real one, which is exactly the mismatch that the
@ApiOkResponse on a POST mistake creates.
Express and Fastify differences
The decorators and the document are platform-independent. What differs is the package set and the plumbing:
| Aspect | Express (default) | Fastify |
|---|---|---|
Extra dependency |
none |
|
UI, JSON and YAML routes |
|
the same; JSON and YAML are served with the same content, and the UI assets ( |
Generated document |
the reference |
identical for the same controllers (compared with the same module set, the only difference was an upload route that exists in one module only) |
Upload documentation |
|
|
Content Security Policy |
Nest’s built-in security headers allow Swagger UI by default |
a directly registered |
Guards, pipes and filters on the docs routes |
not applied; |
not applied; middleware registered before |
The Fastify upload controller, for comparison with the Express one:
import { Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor, type UploadedMultipartFile } from '@nestjs/platform-fastify/multipart';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { FileUploadDto } from './upload.dto.js';
@ApiTags('covers')
@Controller('covers')
export class UploadsFastifyController {
@Post()
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiBody({ description: 'Cover image', type: FileUploadDto })
upload(@UploadedFile() file: UploadedMultipartFile) {
return { name: file.originalname, size: file.size };
}
}
Source: NestJS: OpenAPI, "File upload"
A multipart request returned 201 {"name":"cover.png","size":3} and the documented body matched the Express one. The
Fastify adapter and its plugins are described in
Fastify & HTTP Platforms. The dependency requirement on
@fastify/static is the documented one; what happens without it is not covered here.
Pitfalls
-
Empty schemas. A DTO with neither
@ApiProperty()nor the plugin (or a Standard Schema) is documented withproperties: {}. A runner that does not run the plugin (Vitest,ts-jestwithout a transformer) reproduces this. -
Arrays default to
stringitems. Declare the item type (type: [Number]) or use@ApiBody({ type: [Dto] })for array bodies; the plugin detects arrays itself. -
@ApiOkResponseon aPOST. Nest answers201. Use@ApiCreatedResponse, and add a test that compares documented and real statuses. -
Unregistered models. A model that appears only in
getSchemaPath()needs@ApiExtraModels()(orextraModels); otherwise the$refdangles and no error is raised. -
Enums without
enumName. Each property inlines its copy of the enum and generators emit duplicates. -
Circular imports in ESM.
type: () ⇒ Xsolves Swagger’s problem but not thedesign:typemetadata problem; use a wrapper type such asRelation<T>(see Circular references). -
Mapped types from the wrong package. Import
PartialType,PickType,OmitTypeandIntersectionTypefrom@nestjs/swagger(or@nestjs/graphql), not from@nestjs/mapped-types. -
Missing converter for a Standard Schema library. Without Standard JSON Schema support or a
standardSchemaConverter, the body is documented as a reference to a non-existentObjectschema. Branch by vendor and returnundefinedfor libraries you do not handle. -
Eager
createDocument()beforeenableVersioning(). Paths lose their version prefix; use a factory. -
Documentation routes bypass guards. A global guard does not protect
/docs; use middleware, network controls, or disable the routes in production withuiandraw. -
swaggerUiEnabledis deprecated. Useui;rawis independent of it. -
Unstable generated files. Dynamic defaults (
new Date()) change on every start; setexcludeDynamicDefaults. Large examples are trimmed withexampleMaxDepth. -
A global parameter without a schema. The generated parameter has an empty schema.
-
Declaring security without enforcing it.
@ApiBearerAuth()documents; only a guard enforces. -
Credentials in examples. Never put real tokens or keys in
examplevalues or inaddSecurity()definitions; the document is public by design.
Where the book is outdated
The book introduces Swagger in its REST chapter with @ApiProperty, @ApiTags, @ApiOperation, DocumentBuilder and
SwaggerModule.setup, which is the right starting point. Compared with the current package:
-
It uses
@ApiOkResponseto describe the creation endpoint of aPOST; the correct shorthand is@ApiCreatedResponse, because Nest answers201. -
It predates the document factory (
SwaggerModule.setup('api', app, () ⇒ SwaggerModule.createDocument(…))) that defers generation, theui/rawoptions (replacingswaggerUiEnabled),patchDocumentOnRequest,autoTagControllers,onlyIncludeDecoratedEndpointsand the other document options listed above. -
It does not cover Standard Schema DTOs, which became the alternative to class-based DTOs in Nest 12.
-
It does not cover the CLI plugin options (
introspectComments,classValidatorShim,autoFillEnumName), the SWCPluginMetadataGenerator, or the nullability change in@nestjs/swagger12. -
Its imports and examples are written for CommonJS and an earlier major of the package; today
@nestjs/swaggeris an ES module (still loadable from CommonJS), and examples use.jsimport extensions in the ESM template.
What changed recently
-
@nestjs/swagger12.0.0 (2026-08-27). A native ES module requiring Nest 12 and Node.js 20.19+ or 22.12+; CommonJS applications keep working throughrequire(esm). It also introduced thestandardSchemaConverteroption and reflection of Standard Schema parameters, replacedlodashwithes-toolkitinternally, and made the CLI plugin detectesmCompatibleper file and turn@paramtags into parameter descriptions. -
Nullability is spelled per document version (12.0.0, breaking). Documents that declare 3.1 or later use a type union (
type: ['string', 'null']) instead of the removednullablekeyword; 3.0 documents keepnullable: true. A run confirmed both spellings ({"type":"string","nullable":true}on 3.0 and{"type":["string","null"]}on 3.1). Snapshot tests that assertnullable: truein 3.1 documents need updating. -
12.0.1 and 12.0.2 (2026-08-28 and 2026-09-23). Fixes for ESM static import hoisting,
swagger-ui-init.jshandling of$characters, Standard Schema query parameters merged with@ApiQuery()and schema descriptions promoted to parameters, single literal types treated as enums by the plugin,@fastify/staticresolution,typescriptdeclared as an optional peer dependency, and support for the HTTPQUERYmethod. -
Documented since the docs restructure. OpenAPI 3.2 tag hierarchy (
parent,kind,summarythroughDocumentBuilder.addTag()aftersetOpenAPIVersion('3.2.0')),excludeDynamicDefaults,exampleMaxDepth, theuiandrawoptions, and Swagger UI working with the default Content Security Policy of Nest 12.1’s built-in security headers. -
Related changes elsewhere in the section. Nest 12 route decorators accept Standard Schema (see Pipes & Validation); the full list of version changes is in What’s Changed & Migration.