← Back to the section

Imagine you've released an API, and clients are already working with it. A month later you need to change the response structure — rename a field or remove something extra. But if you simply change the response, everything breaks for the clients.

The solution is versioning: the old API lives at the address /api/v1/orders, the new one — at /api/v2/orders. Clients move to v2 at their own pace, and you don't slow down development.

NestJS can do this with built-in tools — without manual routing and if-chains in the controllers.

How to enable versioning

The setup is done once in main.ts:

import { VersioningType } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.setGlobalPrefix('api');
  app.enableVersioning({
    type: VersioningType.URI,
    defaultVersion: '1',
  });

  await app.listen(3000);
}

After this any controller without an explicit version automatically responds at /api/v1/....

/api/v1/orders        — works
/api/v1/orders/:id    — works
/api/v2/orders        — works (if you add a v2 controller)

setGlobalPrefix('api') is mandatory — without it the routes will be without /api, which breaks the standard format.

How to declare a version on a controller

The @Version decorator explicitly specifies which version a controller belongs to:

import { Controller, Get, Version, Param } from '@nestjs/common';

@Controller('orders')
@Version('1')
export class OrdersControllerV1 {
  constructor(private readonly ordersService: OrdersService) {}

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.ordersService.findOne(id);
  }
}

@Controller('orders')
@Version('2')
export class OrdersControllerV2 {
  constructor(private readonly ordersService: OrdersService) {}

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.ordersService.findOne(id); // the same service
  }
}

Both controllers are registered in AppModule. The business logic in OrdersService is shared — only the DTOs and mapping change.

When to create a v2

Not every API change requires a new version. This is the key question that determines how much work falls to both you and your clients.

Breaking changes — a new version is needed. These are changes that will break existing clients:

  • removing an endpoint or a field in the response
  • renaming a field (customerIduserId)
  • changing a field's type (stringnumber)
  • changing a field's format (datedate-time)
  • removing a value from an enumeration
  • renaming a URL path (/orders/sales-orders)
  • tightening validation (reducing maxLength)
  • adding a required parameter

Non-breaking changes — into the current version. These are changes that don't break clients if they're written correctly:

  • a new optional field in the response
  • an optional new query parameter
  • a new value in an enumeration
  • a new endpoint
  • loosening validation (increasing maxLength)
  • changing the text of an error message

A common mistake is making a v2 just to add one optional field. This is an unnecessary burden on the clients and on the team.

Why a client shouldn't break from new fields

A well-written client ignores unfamiliar fields in the response. Then adding a new field to a v1 response doesn't require a new version:

// The client receives { id: "1", status: "CREATED", metadata: {...} }
// The metadata field is new, the client didn't know about it
const order = await fetch('/api/v1/orders/123').then(r => r.json());
// metadata is simply present in the object — it doesn't break existing code

Likewise for enumerations — the client should handle unknown values as "something new" rather than crashing with an error:

interface OrderResponse {
  status: 'CREATED' | 'CONFIRMED' | 'SHIPPED' | string; // string — for future values
}

Supporting v1 and v2 in parallel

When a breaking change is nevertheless needed, both controllers live side by side. Shared logic — in the service, formatting — in separate mapping functions:

// orders.controller.v1.ts
@Controller('orders')
@Version('1')
export class OrdersControllerV1 {
  constructor(private readonly ordersService: OrdersService) {}

  @Get(':id')
  async findOne(@Param('id') id: string): Promise<OrderResponseV1> {
    const order = await this.ordersService.findOne(id);
    return mapToV1(order);
  }
}

// orders.controller.v2.ts
@Controller('orders')
@Version('2')
export class OrdersControllerV2 {
  constructor(private readonly ordersService: OrdersService) {}

  @Get(':id')
  async findOne(@Param('id') id: string): Promise<OrderResponseV2> {
    const order = await this.ordersService.findOne(id);
    return mapToV2(order); // the new format
  }
}

After v2 is released, the old version doesn't die immediately. The correct order:

  1. Mark v1 as deprecated (@ApiOperation({ deprecated: true })).
  2. Add a Sunset header with the shutdown date.
  3. Monitor traffic on v1.
  4. After the Sunset date, respond with status 410 Gone.

Common mistakes in versioning

Minor versions in the URL (/api/v1.2/) — not used. A version is a whole number: v1, v2, v3. Minor changes, if they aren't breaking, go into the current version without changing the URL.

A version in a query parameter (?version=1) — inconvenient for caching, routing and documentation. The version should be in the path.

Versioning through a header (Accept-Version: v2) — NestJS supports VersioningType.HEADER, but URI versioning is simpler for clients, browsers and tools like curl.

A v2 for the sake of an optional field — an unnecessary version where it isn't required. Clients are forced to migrate even though nothing broke for them.

In short

  • enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }) + setGlobalPrefix('api') — the minimal setup in main.ts.
  • Without an explicit version on the controller — the route gets its version from defaultVersion.
  • @Version('2') on a controller — an explicit binding to v2.
  • Breaking changes require a new version. Non-breaking ones don't.
  • The business logic stays shared in the service; v1 and v2 differ only in DTOs and mapping.
  • A correctly written client ignores new fields and unknown enumeration values.
  • After v2 is released: mark v1 as deprecated → add a Sunset header → shut it down after the date.

Further reading

  • URL and resources in NestJS — the /api/v1/ format, setGlobalPrefix.
  • RFC 9457 errors in NestJS — adding a new error code is non-breaking.
  • OpenAPI in NestJS — how to document parallel versions.