← Back to the section

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 /api to 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:

ActionExample
Get a single resourcegetOrder
Get a listgetOrders
CreatecreateOrder
Replace entirelyupdateOrder
Partially updatepatchOrder
DeletedeleteOrder
Perform an actionconfirmOrder
SearchsearchOrders

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 the required list in the specification.
  • A note?: string field with the value undefined doesn't end up in the JSON response at all — this is correct, don't substitute null.
  • enumName: 'OrderStatus' in @ApiProperty is 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 (/orders instead 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:

  • null in the response where the field is simply absent — use undefined, 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 deprecated X- (X-Request-Id).
  • For errors use Content-Type: application/problem+json, not application/json.
  • In the error type write a specific URN: urn:problem:orders:not-found, not about: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

  • SwaggerModule generates documentation from annotations in the code — wire it up only outside production.
  • Set operationId explicitly via @ApiOperation — without it NestJS generates unreadable names like OrdersController_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.
  • summary is 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 from required; undefined is not serialized into JSON.
  • Verbs in URLs are a sign of a design problem; actions go through POST with a verb in the path.
  • null in 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 — enableVersioning and managing changes.
  • RFC 9457 errors — NestJS — Exception Filters and the ProblemDetails format.
  • JSON and response format — NestJS — undefined vs null, response structure.
  • Query parameters — NestJS — Query DTOs and arrays.