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/swagger generates 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/swagger is the Nest module that connects the two. It is a library, not part of Nest core: it adds SwaggerModule and DocumentBuilder, a family of @Api…​ decorators, optional mapped-type helpers and an optional compiler plugin. It requires NestJS 12 (@nestjs/common and @nestjs/core ^12.0.0).

flowchart LR subgraph source["Your code"] dto["DTO classes: ApiProperty, mapped types, CLI plugin, Standard Schema"] ctl["Controllers: ApiTags, ApiOperation, ApiResponse family, ApiBearerAuth"] cfg["DocumentBuilder: title, version, servers, security schemes, global parameters"] end create["SwaggerModule.createDocument: scans the module graph and reads the metadata"] doc[("OpenAPI document: plain JSON object")] setup["SwaggerModule.setup: mounts UI, JSON and YAML routes"] ui["Swagger UI in the browser"] raw["JSON and YAML endpoints"] file["File written at build time"] gen["Generated clients, mocks and contract tests"] dto --> create ctl --> create cfg --> create create --> doc doc --> setup setup --> ui setup --> raw doc --> file raw --> gen file --> gen
Figure 1. From decorated classes to an OpenAPI document, Swagger UI and generated clients

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:

src/openapi/openapi.setup.ts
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;
}
src/openapi/main.ts
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 to setup(), 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 after init() and once after three requests. Passing the result of createDocument() 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 with app.use() before setup() does run. Both facts hold on Express and Fastify:

src/openapi/lifecycle.e2e-spec.ts (excerpt)
@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));
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

setTitle(), setDescription(), setVersion()

The info object. The version is the version of your API, not of OpenAPI.

setTermsOfService(), setContact(name, url, email), setLicense(name, url)

The remaining info fields.

setOpenAPIVersion('3.1.0')

The openapi field and the spelling rules that depend on it (nullability, tag fields, webhooks). The default is 3.0.0.

addServer(url, description?, variables?)

An entry in servers; call it once per environment.

setExternalDoc(description, url)

The document-level externalDocs.

addTag(name, description?, externalDocs?, options?)

A tag defined up front. The optional fourth argument carries summary, parent and kind (OpenAPI 3.2 tag fields).

addExtension(key, value, location?)

A document-level x- extension.

addSecurity(name, scheme)

A named security scheme in components.securitySchemes.

addBearerAuth(), addOAuth2(), addApiKey(), addBasicAuth(), addCookieAuth(cookieName)

Ready-made schemes registered under the names bearer, oauth2, api-key, basic and cookie unless you pass another name.

addSecurityRequirements(name)

A document-wide security requirement (applies to every operation).

addGlobalResponse(…​responses)

A response added to every operation, typically 500.

addGlobalParameters(…​parameters)

A parameter added to every operation, for example a tenant header.

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

include

every module

Restricts the document to the controllers of the listed modules. include: [ReviewsModule] reduced a 17-path document to one path (/reviews).

extraModels

none

Classes to inspect and add to components.schemas even when no route refers to them (the programmatic form of @ApiExtraModels()).

ignoreGlobalPrefix

false

Leaves the global prefix out of the generated paths (see Global prefix).

deepScanRoutes

false

With include, also scans modules imported by the included ones. In a test, include: [ParentModule] found no routes when PlainModule was only imported by it, and found /plain and /plain/b with deepScanRoutes: true.

operationIdFactory

controllerKey_methodKey (ReviewsController_create)

(controllerKey, methodKey, version?) ⇒ string. Returning methodKey yields operation names such as createUser; the third argument carries the route version (one_v1, two_v2 in the versioning test).

linkNameFactory

controllerKey_methodKey_from_fieldKey

Names the entries of the links object of responses (see @ApiLink() below).

autoTagControllers

true

Tags operations with the controller name without the Controller suffix (PlainController became Plain). With false, only @ApiTags() tags remain.

onlyIncludeDecoratedEndpoints

false

Documents only handlers decorated with @ApiIncludeEndpoint(); an undecorated sibling route disappears.

excludeDynamicDefaults

false

Omits default values that are not plain at runtime (new Date(), class instances), so the document does not change on every restart.

exampleMaxDepth

undefined

Truncates example and examples on component schemas beyond that nesting depth. A per-property @ApiProperty({ exampleMaxDepth }) overrides it.

standardSchemaConverter

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

useGlobalPrefix

false

Prefixes the Swagger routes (UI, JSON, YAML) with the global prefix.

ui

true

false serves no UI; the JSON and YAML definitions stay available.

raw

true

true serves both definitions; an array such as ['json'] serves only those formats; false or [] serves none.

swaggerUiEnabled

true

Deprecated: use ui.

swaggerUrl

none

URL of the API definition that Swagger UI loads.

jsonDocumentUrl, yamlDocumentUrl

<path>-json, <path>-yaml

Paths of the JSON and YAML definitions.

patchDocumentOnRequest

none

(req, res, document) ⇒ document (or a Promise of it): alter the document per request, after generation and before serving (for example to hide internal routes from external callers).

explorer

false

Shows the definition selector (dropdown) in the Swagger UI header.

swaggerOptions

none

Passed to Swagger UI: persistAuthorization, docExpansion, urls and so on.

customCss, customCssUrl

none

Inline CSS, or URL(s) of stylesheets, for the UI page.

customJs, customJsStr

none

URL(s) of scripts, or inline scripts, for the UI page.

customfavIcon, customSiteTitle

none

Favicon and page title.

customSwaggerUiPath

bundled assets

Folder with static Swagger UI assets, such as ./node_modules/swagger-ui-dist.

validatorUrl, url, urls

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:

src/openapi/create-book.dto.ts
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

description, example, examples, default

Documentation values. examples takes an object of named { value } entries.

required

Overrides the default of true.

type

The type, as a class, String/Number/Boolean, a lazy function () ⇒ Class (circular references), [Class], or a raw literal such as 'array' or 'object' with items/properties.

isArray

Marks the property as an array of type; type: [String] is the equivalent short form.

enum, enumName

The allowed values (an array or a TypeScript enum), and the name under which the enum becomes a reusable schema.

format, pattern

'uuid', 'date-time', 'binary' and similar; a regular expression as a string.

minimum, maximum, minLength, maxLength, minItems, maxItems, uniqueItems

Validation keywords, documentation only.

nullable

The value may be null (spelling depends on the document version; see What changed recently).

readOnly, writeOnly, deprecated

Direction and lifecycle flags: id is readOnly in the response, a password-like input is writeOnly.

additionalProperties

For maps (Record<string, number>).

oneOf, anyOf, allOf

Composition (see oneOf, anyOf and allOf).

exampleMaxDepth

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[];
}

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"] }

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;
"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;
}

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:

src/openapi/paginated.dto.ts
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:

src/openapi/editions.dto.ts
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:

src/openapi/category.dto.ts
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:

src/openapi/circular-bad/shelf.dto.ts and volume.dto.ts
// 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):

src/openapi/relation.ts, author.dto.ts and book-summary.dto.ts
// 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.

src/openapi/schema.controller.ts
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
src/openapi/standard-schema.converter.ts
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;
  },
};
src/openapi/valibot.controller.ts
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: [] };
  }
}

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();

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: [] };
  }
}

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 {}

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
  • @ApiOkResponse() (200)

  • @ApiCreatedResponse() (201)

  • @ApiAcceptedResponse() (202)

  • @ApiNoContentResponse() (204)

  • @ApiMovedPermanentlyResponse() (301)

  • @ApiFoundResponse() (302)

  • @ApiDefaultResponse() (the default response)

  • @ApiBadRequestResponse(), @ApiUnauthorizedResponse(), @ApiForbiddenResponse(), @ApiNotFoundResponse()

  • @ApiMethodNotAllowedResponse(), @ApiNotAcceptableResponse(), @ApiRequestTimeoutResponse(), @ApiConflictResponse()

  • @ApiPreconditionFailedResponse(), @ApiTooManyRequestsResponse(), @ApiGoneResponse(), @ApiPayloadTooLargeResponse()

  • @ApiUnsupportedMediaTypeResponse(), @ApiUnprocessableEntityResponse()

  • @ApiInternalServerErrorResponse(), @ApiNotImplementedResponse(), @ApiBadGatewayResponse(), @ApiServiceUnavailableResponse(), @ApiGatewayTimeoutResponse()

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;
}

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();

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 };
}

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:

src/openapi/upload.dto.ts and uploads-express.controller.ts
// 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 };
  }
}

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).

Three less common decorators describe relationships between operations. Applied to a LoansController with OpenAPI 3.1 selected, they produce the document output below:

src/openapi/extras.controller.ts (excerpt)
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;
  }
}
  • @ApiCallbacks() produced callbacks.onReturn["{$request.body#/callbackUrl}"].post on POST /loans, with the request body referring to LoanEventDto and the expected 200 response.

  • @ApiLink() produced a links entry on every response whose model is LoanDto: "LoansController_book_from_id": { "operationId": "LoansController_book", "parameters": { "id": "$response.body#/id" } }. The entry name comes from linkNameFactory.

  • @ApiWebhook('loan-returned') moved the operation from paths to the top-level webhooks object, which exists in OpenAPI 3.1 and later (setOpenAPIVersion('3.1.0')).

  • @ApiResponseProperty() declares a response-only property: LoanDto.id was documented with readOnly: true and its example.

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

@ApiBasicAuth()

addBasicAuth()

basic (http, scheme basic)

@ApiBearerAuth()

addBearerAuth()

bearer (http, scheme bearer, format JWT)

@ApiOAuth2(scopes, name?)

addOAuth2() or addSecurity()

oauth2, with the scopes you list

@ApiCookieAuth()

addCookieAuth(cookieName)

cookie (apiKey in a cookie)

@ApiSecurity(name)

addSecurity(name, scheme) or addApiKey(options, name)

Any named scheme, for example api-key

A controller using every flavour, with a bearer guard that enforces the documented requirement on one route:

src/openapi/secure.controller.ts
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 {};
  }
}

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:

src/openapi/mapped.dto.ts
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)) {}

The five derived schemas in the document:

Class Schema properties (required in parentheses)

UpdateBookDto

title, pages, isbn (none required)

PagesOnlyDto

pages (required)

WithoutTitleDto

pages, isbn (both required)

ShelvedBookDto

title, pages, isbn, shelf (all required)

PatchWithoutIsbnDto

title, pages (none required)

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

@ApiBasicAuth(), @ApiBearerAuth(), @ApiCookieAuth(), @ApiOAuth2(), @ApiSecurity()

Method / Controller

Declare which security scheme an operation uses.

@ApiBody()

Method

Describes the request body (type, schema, description); needed for arrays and uploads.

@ApiConsumes(), @ApiProduces()

Method / Controller

Request and response media types.

@ApiExcludeController()

Controller

Leaves a whole controller out of the document.

@ApiExcludeEndpoint()

Method

Leaves one handler out.

@ApiIncludeEndpoint()

Method

Opts a handler in when onlyIncludeDecoratedEndpoints is true.

@ApiDefaultGetter()

Method

Marks the handler used for links to a model.

@ApiLink()

Method

Adds an OpenAPI link from responses of a model to this operation.

@ApiExtension()

Method / Controller

Adds an x- vendor extension.

@ApiExtraModels()

Method / Controller / Model

Registers schemas that no route references directly.

@ApiHeader(), @ApiHeaders()

Method / Controller

Document request headers.

@ApiHideProperty()

Model

Hides a property from the schema.

@ApiOperation()

Method

Summary, description and other operation fields.

@ApiParam(), @ApiQuery()

Method / Controller

Describe or add path and query parameters.

@ApiSchema()

Model

Renames a schema and adds a description.

@ApiProperty(), @ApiPropertyOptional()

Model

Declare a property; the second makes it optional.

@ApiResponse() and the shorthand responses

Method / Controller

Document a response.

@ApiResponseProperty()

Model

Declares a response-only (readOnly) property.

@ApiTags()

Method / Controller

Group operations under tags.

@ApiCallbacks()

Method / Controller

Document callbacks the API invokes.

@ApiWebhook()

Method

Document an operation as a webhook (OpenAPI 3.1+).

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 required from the question mark (name?: string is optional);

  • sets type or enum from the TypeScript type (arrays included);

  • sets default from an initialiser;

  • copies validation rules from class-validator decorators (classValidatorShim, on by default in the option table);

  • adds a response decorator with a proper status and type to 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:

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 }
      }
    ]
  }
}

Delete the dist folder and rebuild whenever you change the plugin options. With that configuration, this DTO has no @ApiProperty() at all:

src/create-member.dto.ts
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:

src/members.controller.ts
@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

dtoFileNameSuffix

['.dto.ts', '.entity.ts']

File name suffixes the plugin treats as DTOs.

controllerFileNameSuffix

['.controller.ts']

File name suffixes the plugin treats as controllers.

classValidatorShim

true

Reuses class-validator decorators (@Max(10) adds max: 10; @IsEmail() adds format: email).

classTransformerShim

false

true skips properties with class-transformer’s `@Exclude(); 'exclusive' documents only @Expose() or @ApiProperty() properties.

dtoKeyOfComment

'description'

Property key that receives the comment text on ApiProperty.

controllerKeyOfComment

'summary'

Property key that receives the comment text on ApiOperation.

introspectComments

false

Generates descriptions, examples, @remarks, @throws and @param documentation from comments.

skipAutoHttpCode

false

Disables the automatic @HttpCode() the plugin adds to controllers.

skipDefaultValues

false

Does not copy property initialisers into default.

autoFillEnumName

false

Sets enumName to the enum type’s name for enum properties that do not declare one.

esmCompatible

detected per file

Makes the generated code ESM-compatible; when omitted, the plugin resolves each file’s module format from the compiler options and package.json.

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

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:

src/generate-metadata.ts
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"), { /* ... */ }]] } };
};
src/generate-document-swc.ts
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();

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

GET /docs-json

200; paths /api/v1/books, /api/v1/books/bulk, …​ (the prefix is part of the documented paths, but the UI routes are not prefixed)

GET /api/v1/docs2-json

200; paths /books, /books/bulk, …​ (useGlobalPrefix moved the routes under the prefix, ignoreGlobalPrefix removed it from the paths)

GET /docs2-json

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:

src/generate-document.ts
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:

src/offline-document.ts
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
src/client/books-client.ts
import createClient from 'openapi-fetch';
import type { paths } from './schema.js';

export const createBooksClient = (baseUrl: string) => createClient<paths>({ baseUrl });

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');

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:

src/openapi/contract.e2e-spec.ts (excerpt)
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')));

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

@fastify/static, which serves the Swagger UI assets

UI, JSON and YAML routes

/docs, /docs-json (or jsonDocumentUrl), /docs-yaml

the same; JSON and YAML are served with the same content, and the UI assets (swagger-ui-bundle.js) answered 200 on both

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

FileInterceptor from @nestjs/platform-express, Express.Multer.File

FileInterceptor from @nestjs/platform-fastify/multipart, UploadedMultipartFile; @ApiConsumes and @ApiBody are unchanged

Content Security Policy

Nest’s built-in security headers allow Swagger UI by default

a directly registered @fastify/helmet can block the UI and needs a CSP adjustment (see the security headers page)

Guards, pipes and filters on the docs routes

not applied; app.use() middleware registered before setup() is

not applied; middleware registered before setup() is applied

The Fastify upload controller, for comparison with the Express one:

src/openapi/uploads-fastify.controller.ts
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 };
  }
}

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 with properties: {}. A runner that does not run the plugin (Vitest, ts-jest without a transformer) reproduces this.

  • Arrays default to string items. Declare the item type (type: [Number]) or use @ApiBody({ type: [Dto] }) for array bodies; the plugin detects arrays itself.

  • @ApiOkResponse on a POST. Nest answers 201. Use @ApiCreatedResponse, and add a test that compares documented and real statuses.

  • Unregistered models. A model that appears only in getSchemaPath() needs @ApiExtraModels() (or extraModels); otherwise the $ref dangles 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: () ⇒ X solves Swagger’s problem but not the design:type metadata problem; use a wrapper type such as Relation<T> (see Circular references).

  • Mapped types from the wrong package. Import PartialType, PickType, OmitType and IntersectionType from @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-existent Object schema. Branch by vendor and return undefined for libraries you do not handle.

  • Eager createDocument() before enableVersioning(). 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 with ui and raw.

  • swaggerUiEnabled is deprecated. Use ui; raw is independent of it.

  • Unstable generated files. Dynamic defaults (new Date()) change on every start; set excludeDynamicDefaults. Large examples are trimmed with exampleMaxDepth.

  • 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 example values or in addSecurity() 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 @ApiOkResponse to describe the creation endpoint of a POST; the correct shorthand is @ApiCreatedResponse, because Nest answers 201.

  • It predates the document factory (SwaggerModule.setup('api', app, () ⇒ SwaggerModule.createDocument(…​))) that defers generation, the ui/raw options (replacing swaggerUiEnabled), patchDocumentOnRequest, autoTagControllers, onlyIncludeDecoratedEndpoints and 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 SWC PluginMetadataGenerator, or the nullability change in @nestjs/swagger 12.

  • Its imports and examples are written for CommonJS and an earlier major of the package; today @nestjs/swagger is an ES module (still loadable from CommonJS), and examples use .js import extensions in the ESM template.

What changed recently

  • @nestjs/swagger 12.0.0 (2026-08-27). A native ES module requiring Nest 12 and Node.js 20.19+ or 22.12+; CommonJS applications keep working through require(esm). It also introduced the standardSchemaConverter option and reflection of Standard Schema parameters, replaced lodash with es-toolkit internally, and made the CLI plugin detect esmCompatible per file and turn @param tags 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 removed nullable keyword; 3.0 documents keep nullable: true. A run confirmed both spellings ({"type":"string","nullable":true} on 3.0 and {"type":["string","null"]} on 3.1). Snapshot tests that assert nullable: true in 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.js handling 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/static resolution, typescript declared as an optional peer dependency, and support for the HTTP QUERY method.

  • Documented since the docs restructure. OpenAPI 3.2 tag hierarchy (parent, kind, summary through DocumentBuilder.addTag() after setOpenAPIVersion('3.2.0')), excludeDynamicDefaults, exampleMaxDepth, the ui and raw options, 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.