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 (
customerId→userId) - changing a field's type (
string→number) - changing a field's format (
date→date-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:
- Mark v1 as deprecated (
@ApiOperation({ deprecated: true })). - Add a
Sunsetheader with the shutdown date. - Monitor traffic on v1.
- 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 inmain.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
Sunsetheader → 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.