OpenAPI docs with @nestjs/swagger: Making API contracts visible
When I joined a team where the frontend and backend worked in different time zones, I learned quickly that email descriptions of new endpoints don't scale. OpenAPI docs with @nestjs/swagger solved that problem by turning my NestJS decorators into a live, interactive specification that my frontend team could explore without Slack ping-pong. In this article, I'll walk you through building API documentation your team will actually use—not just generate and ignore.
Why OpenAPI documentation matters in production systems
I've shipped enough features to know that API contracts break silently. A backend changes a response shape, frontend code still compiles, and six hours later your mobile app shows blank screens in production. Swagger gives you a single source of truth: your code decorators generate the spec, so drift happens only if you deliberately ignore the warnings.
Real-world scenario: I worked on a trading platform where order statuses could be pending, filled, or rejected. Without documented contracts, the mobile team assumed a string, the web team used an enum, and the backend returned an integer. A Swagger spec would have caught that in minutes during development instead of in production.
Bootstrap Swagger: Connecting decorators to your OpenAPI document
Start by installing the Swagger module and OpenAPI core library:
npm install @nestjs/swagger swagger-ui-expressThen configure it in your main.ts file:
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = new DocumentBuilder()
.setTitle('CRM API')
.setDescription('Sales pipeline and lead management platform')
.setVersion('1.0.0')
.addBearerAuth()
.addServer('https://api.example.com', 'Production')
.addServer('http://localhost:3000', 'Development')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
await app.listen(3000);
}
bootstrap();After this, visit http://localhost:3000/docs and you'll see an interactive UI. But it's empty because your endpoints don't have decorators yet. That comes next.
The DocumentBuilder pattern is simple: chain methods to add metadata, then call build(). The addBearerAuth() tells Swagger that your API uses JWT tokens in the Authorization header. I always add multiple servers to distinguish production from development—saves your frontend team from accidentally hitting prod during testing.
Describe responses: Type your endpoints so clients know what they're getting
Without response decorators, Swagger shows generic objects. With them, you give frontend teams exact types they can generate code from. Here's a realistic example from a CRM system:
import { ApiOkResponse, ApiCreatedResponse, ApiBadRequestResponse, ApiUnauthorizedResponse } from '@nestjs/swagger';
export class LeadDto {
id: string;
email: string;
company: string;
status: 'new' | 'contacted' | 'qualified' | 'lost';
score: number;
createdAt: Date;
}
@Controller('leads')
export class LeadsController {
constructor(private readonly leadsService: LeadsService) {}
@Get(':id')
@ApiOkResponse({
description: 'Lead retrieved successfully',
type: LeadDto
})
@ApiUnauthorizedResponse({ description: 'Missing or invalid Bearer token' })
@ApiBadRequestResponse({ description: 'Invalid lead ID format' })
async getLeadById(@Param('id') id: string): Promise {
return this.leadsService.findById(id);
}
@Post()
@ApiCreatedResponse({
description: 'Lead created successfully',
type: LeadDto
})
@ApiBadRequestResponse({ description: 'Validation failed on request body' })
async createLead(@Body() createLeadDto: CreateLeadDto): Promise {
return this.leadsService.create(createLeadDto);
}
}Each decorator tells Swagger: "This endpoint returns this shape." When your frontend engineer opens the docs, they see exact fields, types, and examples. If you add an optional field later, you document it in the DTO, and the spec updates automatically.
I pair decorators with detailed DTOs:
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateLeadDto {
@ApiProperty({ description: 'Email address', example: 'alice@example.com' })
email: string;
@ApiProperty({ description: 'Company name', example: 'Acme Corp' })
company: string;
@ApiPropertyOptional({ description: 'Internal notes', example: 'Warm intro from partner' })
notes?: string;
}
export class LeadDto extends CreateLeadDto {
@ApiProperty({ description: 'Unique identifier' })
id: string;
@ApiProperty({ description: 'Lead status', enum: ['new', 'contacted', 'qualified', 'lost'] })
status: string;
@ApiProperty({ description: 'Timestamp of creation' })
createdAt: Date;
}The example field is gold for frontend developers. Instead of guessing what email format you expect, they see "alice@example.com" right in the docs. This reduces clarifying questions by 70% in my experience.
Generate typed clients: Turning specs into code
The real power of OpenAPI emerges when you feed your spec into code generators. Two solid choices exist: openapi-typescript and Orval.
Using openapi-typescript:
npx openapi-typescript http://localhost:3000/docs-json -o ./src/generated/api.tsThis generates TypeScript types matching your API exactly. Your frontend can then import and use them:
import { paths } from './generated/api';
type GetLeadResponse = paths['/leads/{id}']['get']['responses']['200']['content']['application/json'];
// Your IDE now knows getLeadResponse shape without manual typing
const response: GetLeadResponse = {
id: '123',
email: 'alice@example.com',
company: 'Acme',
status: 'qualified',
score: 85,
createdAt: new Date()
};Using Orval for full client generation:
npm install orval --save-devCreate an orval.config.ts:
import { defineConfig } from 'orval';
export default defineConfig({
api: {
input: 'http://localhost:3000/docs-json',
output: {
target: './src/generated/api.ts',
client: 'fetch'
}
}
});Orval generates not just types but entire API client functions. Your frontend calls:
// Generated by Orval
export const getLeadById = (id: string) =>
fetch(`/leads/${id}`, { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.json());I've used Orval on three projects now. It removes entire categories of bugs because the generated client and backend are always in sync. When I add a required field to CreateLeadDto, the frontend code that calls createLead immediately breaks at compile time with a helpful error—not at runtime.
Protect it: Controlling access to your documentation
Swagger is incredibly useful but also leaks implementation details. In production, you probably want it gated or disabled entirely.
Simple environment-based toggle:
async function bootstrap() {
const app = await NestFactory.create(AppModule);
if (process.env.NODE_ENV !== 'production') {
const config = new DocumentBuilder()
.setTitle('CRM API')
.setVersion('1.0.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
}
await app.listen(3000);
}
bootstrap();For staging or internal environments where you want docs available, add basic auth:
import * as basicAuth from 'express-basic-auth';
const swaggerAuthMiddleware = basicAuth({
users: { admin: process.env.SWAGGER_PASSWORD || 'dev123' },
challenge: true,
realm: 'CRM API Docs'
});
async function bootstrap() {
const app = await NestFactory.create(AppModule);
if (process.env.NODE_ENV !== 'production') {
app.use('/docs', swaggerAuthMiddleware);
const config = new DocumentBuilder()
.setTitle('CRM API')
.setVersion('1.0.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
}
await app.listen(3000);
}
bootstrap();I always gate docs in production. Someone can still download your OpenAPI spec if they're determined, but you're not handing them a clickable UI that lists every endpoint.
Common mistakes and how to avoid them
- Decorators that drift from actual behavior: I've seen @ApiOkResponse claim an endpoint returns LeadDto when it actually returns null on not found. Use @ApiNotFoundResponse too. Make decorators match reality or they become worse than useless.
- Vague descriptions: "Returns the lead" tells nobody anything. Write "Returns a single lead record with sales history and last contact date." Your future self and teammates will appreciate it.
- Forgetting to update docs when you change responses: Decorators are easy to forget when you refactor. I add a pre-commit hook that checks if response shapes changed without updating decorators.
- Not versioning your API: Set version in DocumentBuilder. When you ship breaking changes, bump to 2.0.0. Your frontend team can stay on v1 while they prepare migration code.
- Assuming frontend can generate all clients: Orval is powerful but not universal. Test code generation early in a project before you've shipped without it.
Key takeaways
- Bootstrap Swagger with DocumentBuilder and SwaggerModule.setup() to turn decorators into live docs.
- Use @ApiOkResponse, @ApiBadRequestResponse, and similar decorators to describe what clients actually get back.
- Pair decorators with detailed DTOs that include examples—this cuts clarifying questions dramatically.
- Feed your OpenAPI spec to openapi-typescript or Orval to generate typed clients and eliminate client-server type mismatches.
- Gate documentation in production with environment checks or basic auth to avoid leaking implementation details.
- Keep decorators in sync with actual code behavior, or they become misleading noise.






Comments
Loading comments…