Skip to main content

How to document a NestJS API with Swagger?

@nestjs/swagger generates interactive OpenAPI documentation from TypeScript decorators on your controllers, DTOs, and routes. No separate YAML file, no manual sync.

Theory

TL;DR

  • Install @nestjs/swagger, call SwaggerModule.setup() in main.ts, open /api/docs
  • Think of it as an IKEA manual that builds itself: the decorators are the parts list, the UI is the assembled blueprint with a "Try it" button
  • @ApiProperty() on DTO fields, @ApiTags() and @ApiResponse() on controllers
  • Docs regenerate on every app restart, so they always match your live routes
  • Skip for internal services with no external consumers; use it whenever frontend or QA teams need to test endpoints

Quick setup

typescript
// main.ts import { NestFactory } from '@nestjs/core'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); const config = new DocumentBuilder() .setTitle('Users API') .setDescription('User management endpoints') .setVersion('1.0') .addBearerAuth() // adds "Authorize" button in UI .addTag('users') .build(); const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api/docs', app, document); // serves UI at /api/docs await app.listen(3000); } bootstrap(); // Visit http://localhost:3000/api/docs

DocumentBuilder assembles the OpenAPI config. createDocument() scans all loaded modules and builds the spec. setup() serves it as an HTML page backed by the JSON schema. Three calls, done.

Decorating DTOs and controllers

Every DTO field needs @ApiProperty(). Without it, Swagger shows the field as {} with no type or example.

typescript
// create-user.dto.ts import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreateUserDto { @ApiProperty({ example: 'alice@example.com', description: 'Unique email' }) email: string; @ApiProperty({ example: 'Alice', minLength: 2 }) name: string; @ApiPropertyOptional({ example: 25, minimum: 18 }) age?: number; @ApiProperty({ enum: ['user', 'admin'], default: 'user' }) role: string; }

Controllers get @ApiTags() for grouping, @ApiOperation() for a human-readable summary, and @ApiResponse() per status code.

typescript
// users.controller.ts import { Controller, Post, Get, Body, Param } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger'; @ApiTags('users') // groups all routes under "users" tab @ApiBearerAuth() // shows lock icon; JWT required @Controller('users') export class UsersController { @Get() @ApiOperation({ summary: 'Get all users' }) @ApiQuery({ name: 'page', required: false, type: Number }) @ApiResponse({ status: 200, description: 'Paginated user list', type: [UserResponseDto] }) findAll() { /* ... */ } @Get(':id') @ApiParam({ name: 'id', type: Number }) @ApiResponse({ status: 200, type: UserResponseDto }) @ApiResponse({ status: 404, description: 'Not found' }) findOne(@Param('id') id: string) { /* ... */ } @Post() @ApiOperation({ summary: 'Create a new user' }) @ApiResponse({ status: 201, type: UserResponseDto }) @ApiResponse({ status: 400, description: 'Validation error' }) create(@Body() dto: CreateUserDto) { /* ... */ } }

Key difference from Postman collections

Postman collections and README files rot. A route changes, someone forgets to update the docs, and the docs start lying. Swagger scans decorators at startup and builds fresh JSON every time the app starts. The code is the single source of truth. No copy-paste, no drift.

One pattern I've seen work well in production: export /api/docs-json in CI and diff it against the previous build as an automated breaking-change check.

When to use

  • Frontend or mobile team consuming your API: use Swagger, they get a "Try it" button without reading your code
  • QA engineers testing auth flows: @ApiBearerAuth() and the "Authorize" button handle JWT directly in the UI
  • Monorepo with multiple services: a shared /docs endpoint speeds up cross-team integration
  • Versioned API: .setVersion() and .addTag() document what changed
  • Tiny prototype, under 5 endpoints, no external consumers: skip it for now

How it works at startup

SwaggerModule.createDocument() uses reflect-metadata to scan every @Controller(), @Get(), @Post(), and @ApiProperty() decorator across all modules loaded into the NestJS DI container. It builds an OpenAPI v3 JSON object in memory. setup() then serves two things at the specified path: the raw JSON at [path]-json (e.g. /api/docs-json) and the Swagger UI HTML page that reads that JSON. The UI assets come from @nestjs/swagger/dist/ui. Nothing is pre-compiled; it regenerates on every bootstrap.

Common mistakes

No reflect-metadata import

typescript
// Wrong: missing at top of main.ts // @ApiProperty() metadata vanishes without any error

NestJS CLI scaffolds this automatically. Custom setups sometimes miss it. Add import 'reflect-metadata' as the first line of main.ts if docs come out empty.

Circular DTO references

typescript
// Wrong: UserDto references ProfileDto which references UserDto // Error: Maximum call stack size exceeded // Fix: use a factory function to break the cycle @ApiProperty({ type: () => ProfileDto }) profile: ProfileDto;

@ApiProperty() on private fields

typescript
// Wrong @ApiProperty() private email: string; // Swagger shows it, TypeScript hides it at runtime

Use public fields. Or use @Expose() from class-transformer if you need serialization control.

No @ApiTags() on controllers

Without tags, every endpoint lands in the "default" group. With 30+ routes that becomes unusable. Add @ApiTags('users') to each controller and optionally .addTag('users', 'User management endpoints') in DocumentBuilder for descriptions.

File uploads without @ApiConsumes()

typescript
@Post('upload') @ApiConsumes('multipart/form-data') // required, otherwise Swagger shows JSON input @UseInterceptors(FileInterceptor('file')) uploadFile(@UploadedFile() file: Express.Multer.File) { /* ... */ }

Without this decorator, the UI renders a JSON body field instead of a file picker.

Version mismatch

@nestjs/swagger@7.1+ is required for NestJS 10. Earlier versions break enum generation and some response schemas without obvious errors. Check both versions before debugging strange schema output.

Real-world usage

  • NestJS CRUD boilerplates (Trilon starters): Swagger on day one, new devs onboard without reading controllers
  • Auth microservice: @ApiBearerAuth() documents the JWT flow, QA tests protected routes via the UI directly
  • Monolith-to-microservices migration: export /api/docs-json for contract testing with Pact
  • Production lockdown: wrap SwaggerModule.setup() in if (process.env.NODE_ENV !== 'production') or proxy /api/docs through an auth guard

Follow-up questions

Q: How do you document enums and nested DTOs?
A: Use @ApiProperty({ enum: Category, enumName: 'Category' }) for enums - this renders a dropdown in the UI. For nested objects: @ApiProperty({ type: () => AddressDto }). For arrays: @ApiProperty({ type: [ImageDto] }). The factory function form also prevents circular reference errors.

Q: Does Swagger read class-validator decorators automatically?
A: No. @IsEmail() and @IsString() have no effect on the Swagger schema. You still need @ApiProperty() on each field. Some teams use @nestjs/mapped-types helpers like PartialType() and OmitType(), which carry over @ApiProperty() metadata from the base class automatically.

Q: How do you generate a client SDK from the docs?
A: Fetch /api/docs-json to get the raw OpenAPI spec, then pass it to openapi-generator-cli or Orval. Orval is common in NestJS monorepos for generating typed React Query hooks directly from the spec.

Q: (Senior) How do you document routes protected by dynamic guards?
A: Guards don't affect the schema. Add @ApiBearerAuth() to the controller and @ApiResponse({ status: 401, description: 'Unauthorized' }) to each protected method. For dynamic path segments, @ApiParam({ name: 'id', type: Number, example: 42 }) ensures the UI shows a working input field with a usable value.

Q: How do you secure /api/docs in production?
A: Two common approaches. First: if (process.env.NODE_ENV !== 'production') SwaggerModule.setup(...) so docs only exist in dev and staging. Second: proxy the docs route through a guard that checks a session or internal API key. The second option works for staging environments where QA needs access.

Examples

Basic: DTO with enum and optional field

typescript
// create-product.dto.ts import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsString, IsEnum, IsOptional } from 'class-validator'; enum Category { Electronics = 'electronics', Clothing = 'clothing', } export class CreateProductDto { @ApiProperty({ example: 'Laptop Pro', minLength: 2 }) @IsString() name: string; @ApiProperty({ enum: Category, enumName: 'Category' }) // renders as dropdown in Swagger UI @IsEnum(Category) category: Category; @ApiPropertyOptional({ type: [String], example: ['sale', 'new'] }) @IsOptional() tags?: string[]; } // Swagger UI: name text input, category dropdown, optional tags array

Enums become dropdowns in the UI. ApiPropertyOptional marks the field as not required in the schema. Both map directly to what class-validator enforces at runtime.

Intermediate: Auth endpoint with Bearer token

typescript
// auth.controller.ts import { Controller, Post, Body } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger'; export class LoginDto { @ApiProperty({ example: 'alice@example.com' }) email: string; @ApiProperty({ example: 'secret123' }) password: string; } @Controller('auth') @ApiTags('auth') export class AuthController { @Post('login') @ApiOperation({ summary: 'Login and get JWT token' }) @ApiResponse({ status: 200, description: 'Returns access_token' }) @ApiResponse({ status: 401, description: 'Invalid credentials' }) login(@Body() loginDto: LoginDto) { return { access_token: 'jwt-token' }; } } // In main.ts DocumentBuilder, add: // .addBearerAuth() // Then on any protected controller: // @ApiBearerAuth() // This connects the route to the global "Authorize" button in the UI

The .addBearerAuth() call in main.ts registers the security scheme. @ApiBearerAuth() on a controller connects it to that scheme. QA engineers click "Authorize", paste a token, and test protected routes without writing a single curl command.

Advanced: Response DTO with field exclusion

typescript
// user-response.dto.ts import { ApiProperty } from '@nestjs/swagger'; import { Exclude } from 'class-transformer'; export class UserResponseDto { @ApiProperty() id: number; @ApiProperty() name: string; @ApiProperty() email: string; @Exclude() // excluded from both Swagger schema and HTTP response password: string; } // users.controller.ts import { UseInterceptors, ClassSerializerInterceptor, SerializeOptions, Get, Param } from '@nestjs/common'; import { ParseIntPipe } from '@nestjs/common'; import { ApiResponse } from '@nestjs/swagger'; @Get(':id') @UseInterceptors(ClassSerializerInterceptor) @SerializeOptions({ type: UserResponseDto }) @ApiResponse({ status: 200, type: UserResponseDto }) findOne(@Param('id', ParseIntPipe) id: number) { return this.usersService.findOne(id); // password stripped before sending response }

@Exclude() from class-transformer keeps the password field out of the Swagger schema and out of the actual HTTP response. Pair it with ClassSerializerInterceptor applied globally via APP_INTERCEPTOR in a module, or per route with @UseInterceptors().

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?