When you write a REST API, other developers need to understand how to use it. OpenAPI is the standard for describing REST APIs: the structure of endpoints, request and response formats, possible error codes. NestJS can generate an OpenAPI document automatically — straight from your controller code.
Let's look at how to set this up properly, and which mistakes come up most often.
What SwaggerModule is and why you need it
API documentation used to be written by hand: a separate YAML or JSON file that had to be updated after every change. The file quickly drifted apart from the code — and developers stopped trusting it.
SwaggerModule in NestJS solves this problem: the documentation is generated from annotations on your controllers when the application starts. Change the controller — the documentation changes with it.
// main.ts
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' });
if (process.env.NODE_ENV !== 'production') {
const config = new DocumentBuilder()
.setTitle('Orders API')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
}
await app.listen(3000);
}
A few important points:
setGlobalPrefix('api')adds/apito all routes — the documentation and the code use the same paths.- URI versioning gives you
/api/v1/.... - Swagger UI is shown only outside production — you shouldn't expose the internal details of your API to everyone on the internet.
operationId — the operation name
Every route in an OpenAPI specification has an operationId — a unique name. This name becomes the method name in automatically generated client libraries.
If you don't set operationId explicitly, NestJS generates it itself: you end up with something like OrdersController_findAll — a useless name for a consumer of your API.
@Controller('orders')
@ApiTags('Orders')
export class OrdersController {
@Get()
@ApiOperation({ operationId: 'getOrders', summary: 'List orders' })
findAll(@Query() query: OrdersQueryDto): Promise<OrdersPageDto> { ... }
@Get(':orderId')
@ApiOperation({ operationId: 'getOrder', summary: 'Get order' })
findOne(@Param('orderId') orderId: string): Promise<OrderDto> { ... }
@Post()
@ApiOperation({ operationId: 'createOrder', summary: 'Create order' })
create(@Body() body: CreateOrderDto): Promise<OrderDto> { ... }
@Put(':orderId')
@ApiOperation({ operationId: 'updateOrder', summary: 'Replace order' })
replace(@Param('orderId') orderId: string, @Body() body: UpdateOrderDto): Promise<OrderDto> { ... }
@Patch(':orderId')
@ApiOperation({ operationId: 'patchOrder', summary: 'Partially update order' })
patch(@Param('orderId') orderId: string, @Body() body: PatchOrderDto): Promise<OrderDto> { ... }
@Delete(':orderId')
@HttpCode(204)
@ApiOperation({ operationId: 'deleteOrder', summary: 'Delete order' })
remove(@Param('orderId') orderId: string): Promise<void> { ... }
@Post(':orderId/confirm')
@HttpCode(200)
@ApiOperation({ operationId: 'confirmOrder', summary: 'Confirm order' })
confirm(@Param('orderId') orderId: string): Promise<OrderDto> { ... }
}
Naming convention for operationId:
| Action | Example |
|---|---|
| Get a single resource | getOrder |
| Get a list | getOrders |
| Create | createOrder |
| Replace entirely | updateOrder |
| Partially update | patchOrder |
| Delete | deleteOrder |
| Perform an action | confirmOrder |
| Search | searchOrders |
Format: camelCase, action + resource.
@ApiTags — grouping endpoints
In Swagger UI, endpoints can be grouped by tags. Without tags, all routes end up in one big pile that's hard to navigate.
The rule is simple: one tag per resource, plural with a capital letter.
@Controller('orders')
@ApiTags('Orders')
export class OrdersController { ... }
@Controller('customers')
@ApiTags('Customers')
export class CustomersController { ... }
If a resource has nested endpoints — they use the tag of the parent resource:
@Controller('orders/:orderId/items')
@ApiTags('Orders') // don't create a separate OrderItems tag
export class OrderItemsController {
@Get()
@ApiOperation({ operationId: 'getOrderItems', summary: 'Order items' })
findAll(@Param('orderId') orderId: string): Promise<OrderItemsPageDto> { ... }
@Post()
@ApiOperation({ operationId: 'addOrderItem', summary: 'Add item' })
add(@Param('orderId') orderId: string, @Body() body: AddOrderItemDto): Promise<OrderItemDto> { ... }
}
This way, in Swagger UI all order endpoints end up in a single Orders group.
Path parameters
When a route has several path parameters, it's important to give each one a unique name:
// Correct: unique names
@Get(':orderId/items/:itemId')
@ApiParam({ name: 'orderId', schema: { type: 'string', format: 'uuid' } })
@ApiParam({ name: 'itemId', schema: { type: 'string', format: 'uuid' } })
findItem(
@Param('orderId') orderId: string,
@Param('itemId') itemId: string,
): Promise<OrderItemDto> { ... }
// Wrong: identical :id — Swagger/Redoc don't work correctly
@Get(':id/items/:id')
findItem(@Param('id') ...) { ... }
Swagger and Redoc identify parameters by name. Two parameters with the same name id are a conflict that the tools handle unpredictably.
summary and description
summary is a short description of the route (up to 80 characters). It's shown right next to the route in Swagger UI. It should be present on every endpoint.
description is an extended description in Markdown format. Add it only when the behavior is not obvious:
@Post(':orderId/confirm')
@HttpCode(200)
@ApiOperation({
operationId: 'confirmOrder',
summary: 'Confirm order',
description: `Moves the order from status CREATED to CONFIRMED.
The order must contain at least one item.
After confirmation, changing the order's contents is not possible.`,
})
confirm(@Param('orderId') orderId: string): Promise<OrderDto> { ... }
An empty description: '' is worse than not having one — don't add it just for the sake of it.
Schemas from DTO classes
In NestJS, OpenAPI schemas are generated from your DTO classes. There are two approaches.
With the CLI plugin (recommended — less code):
// nest-cli.json
{
"compilerOptions": {
"plugins": [{ "name": "@nestjs/swagger" }]
}
}
// The plugin infers types from TypeScript — @ApiProperty is not needed
export class CreateOrderDto {
customerId: string;
items: CreateOrderItemDto[];
note?: string;
}
Without the plugin — explicit decorators:
export class OrderDto {
@ApiProperty({ format: 'uuid' })
orderId: string;
@ApiProperty({ enum: OrderStatus, enumName: 'OrderStatus' })
status: OrderStatus;
@ApiProperty({ type: [OrderItemDto] })
items: OrderItemDto[];
@ApiPropertyOptional()
note?: string;
}
A few details that are important to know:
@ApiPropertyOptional()excludes the field from therequiredlist in the specification.- A
note?: stringfield with the valueundefineddoesn't end up in the JSON response at all — this is correct, don't substitutenull. enumName: 'OrderStatus'in@ApiPropertyis mandatory. Without it, Swagger creates an anonymous enum in every schema instead of a single reusable component.
Enum values are in UPPER_SNAKE_CASE in English:
export enum OrderStatus {
CREATED = 'CREATED',
CONFIRMED = 'CONFIRMED',
SHIPPED = 'SHIPPED',
DELIVERED = 'DELIVERED',
CANCELLED = 'CANCELLED',
}
Errors in OpenAPI
The error schema must be described in the specification — otherwise the consumer doesn't know what to expect on 400 or 404.
export class ProblemDetailsDto {
@ApiProperty()
type: string;
@ApiProperty()
title: string;
@ApiProperty()
status: number;
@ApiProperty()
detail: string;
@ApiProperty()
instance: string;
@ApiProperty({ enum: ErrorCode, enumName: 'ErrorCode' })
code: ErrorCode;
@ApiPropertyOptional({ type: [ViolationDto] })
violations?: ViolationDto[];
}
@Post()
@ApiOperation({ operationId: 'createOrder', summary: 'Create order' })
@ApiResponse({ status: 201, type: OrderDto })
@ApiResponse({
status: 400,
description: 'Validation error',
schema: {
example: {
type: 'urn:problem:orders:validation-error',
title: 'Validation Error',
status: 400,
detail: 'Request validation failed',
instance: '/api/v1/orders',
code: 'VALIDATION_ERROR',
violations: [
{ field: 'customerId', message: 'customerId is required' },
],
},
},
})
@ApiResponse({ status: 404, description: 'Customer not found' })
create(@Body() body: CreateOrderDto): Promise<OrderDto> { ... }
Content-Type: application/problem+json for errors is set in the Exception Filter — not here. But an example in the specification helps the client understand the actual format without reading the source code.
Common API design mistakes
Verbs in URLs and wrong path structure
A common mistake is to put verbs in the URL:
// Wrong
@Get('get-orders')
@Get('orderItems') // camelCase in the path
@Get('orders/') // trailing slash
// Correct
@Get() // GET /orders
@Get('order-items') // kebab-case
@Get('orders') // no slash
A URL describes a resource, not an action. If you need to perform an action — use POST with a verb in the last segment: POST /orders/:id/confirm.
Don't pass the resource ID in the body on PUT/PATCH — only in the path parameter:
// Wrong: orderId in the body
@Put(':orderId')
update(@Body() body: { orderId: string, ... }) { ... }
// Correct: orderId in @Param
@Put(':orderId')
update(@Param('orderId') orderId: string, @Body() body: UpdateOrderDto) { ... }
Avoid more than two levels of nesting. GET /orders/:orderId/items is fine. GET /orders/:orderId/items/:itemId/subitems is a sign of poor resource structure.
Versioning
- Version in a query parameter (
?api-version=1) — inconvenient to cache and not obvious. - Minor version in the path (
/v1.2/) — useless, clients only care about the major version. - No global prefix (
/ordersinstead of/api/v1/orders) — mixes the API with the frontend. - A new major version just to add an optional field —
@ApiPropertyOptional()is enough.
JSON and response format
Common mistakes in the response format:
nullin the response where the field is simply absent — useundefined, it isn't serialized.- An empty string
""instead of an absent field. - A wrapper like
{ success: true, data: {...} }— return the resource directly.
Headers and errors
- Non-standard headers are better named with a domain prefix (
Shop-Request-Id), rather than the deprecatedX-(X-Request-Id). - For errors use
Content-Type: application/problem+json, notapplication/json. - In the error
typewrite a specific URN:urn:problem:orders:not-found, notabout:blank. - A stack trace in the body of a 500 error — never send it to users.
Deprecating endpoints
When you mark a route as deprecated via @ApiOperation({ deprecated: true }), add a Sunset header with the shutdown date. Otherwise consumers don't know how much time they have to migrate.
Action endpoints
For actions on a resource use POST with a verb in the URL:
// Correct
@Post(':orderId/confirm') // POST /orders/:orderId/confirm
// Wrong: PUT/PATCH for an action
@Put(':orderId/confirm')
// Wrong: a noun instead of a verb
@Post(':orderId/confirmation')
In short
SwaggerModulegenerates documentation from annotations in the code — wire it up only outside production.- Set
operationIdexplicitly via@ApiOperation— without it NestJS generates unreadable names likeOrdersController_findAll. @ApiTags— one tag per resource, plural with a capital letter; nested controllers inherit the parent's tag.- Name path parameters uniquely:
:orderId,:itemId, not two:id. summaryis mandatory on every route;description— only when the logic is not obvious.- Schemas come from DTO classes; use the CLI plugin to write fewer decorators.
@ApiPropertyOptional()excludes the field fromrequired;undefinedis not serialized into JSON.- Verbs in URLs are a sign of a design problem; actions go through
POSTwith a verb in the path. nullin the response instead of an absent field,{ success, data }wrappers, a stack trace in errors — common mistakes worth avoiding from the very start.
Further reading
- URLs and resources — NestJS — how to build paths and name parameters.
- Versioning — NestJS —
enableVersioningand managing changes. - RFC 9457 errors — NestJS — Exception Filters and the ProblemDetails format.
- JSON and response format — NestJS —
undefinedvsnull, response structure. - Query parameters — NestJS — Query DTOs and arrays.