Skip to content

API Versioning

Aman Kumar Singh7 min read
Part 29 of 40From the NestJS Production Guide series
API Versioning — article by Aman Kumar Singh

Last time in this series, I covered API Documentation with Swagger, because an API nobody can read is an API nobody can safely change. That's the natural segue into today's topic: once clients start depending on your endpoints, "safely change" stops being a nice-to-have and becomes the whole game. Versioning is how you change a contract without breaking everyone who already signed it.

This is part of the NestJS Production Guide, and it sits early in the foundation phase for a reason. Versioning is one of those decisions that's cheap to make correctly on day one and expensive to retrofit once three mobile app versions and a partner integration are pinned to your current response shape.

I'll be upfront: most teams overbuild this. You don't need a versioning strategy for an internal admin API with one consumer you control. You need one the moment an external client, a mobile app you can't force-update, or a partner integration depends on your endpoints staying stable while you keep shipping. This article covers when that moment arrives, how NestJS's built-in versioning works, and where teams get hurt when they either skip it or over-engineer it.

Why versioning matters at all

The core problem versioning solves is simple: you will need to change your API in ways that break existing clients, and you can't force every client to upgrade the instant you deploy. A mobile app in the App Store review queue, a partner's batch job that runs weekly, a frontend that's a version behind because a release got delayed: all of these are clients you don't control, running against a contract you're about to change.

Without versioning, every breaking change becomes a coordination problem. You either hold your changes hostage to every consumer's release schedule, or you break things and deal with the support fallout. Neither is sustainable past a handful of consumers.

The important nuance is that not every change is breaking. Adding an optional field to a response is safe. Adding a new endpoint is safe. Renaming a field, changing a field's type, removing a field, or changing status code semantics is breaking. A lot of "we need to version everything" panic comes from not distinguishing these two categories. If you're disciplined about additive changes, you can go a long time on a single version.

Picking a versioning strategy

NestJS supports three built-in strategies through VersioningType, and the choice matters more for your API's ergonomics than most teams initially think.

URI versioning (/v1/users, /v2/users) is the most common choice, and for good reason: it's visible in logs, curl commands, and browser URLs without inspecting headers. It's also the easiest for API consumers to reason about and the easiest to route at the infrastructure layer (a load balancer or API gateway can route /v2/* to a different upstream without touching application code).

Header versioning (a custom header like X-API-Version: 2) keeps URLs clean, which some teams prefer for aesthetic or REST-purity reasons. In practice, it's harder to debug: you can't just look at a URL to know which version a request hit, and it's easy for a client to forget the header and silently fall onto a default.

Media type versioning (via the Accept header, like application/vnd.myapi.v2+json) is the most "correct" from a REST-purist standpoint but the least ergonomic in practice. Almost nobody outside API design conference talks actually uses it, and tooling support (Postman, curl, browser devtools) is worse for it than for the other two.

My default is URI versioning unless there's a specific reason not to. It's boring, which is exactly what you want from infrastructure that everyone depends on.

Enable it in main.ts:

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { VersioningType } from '@nestjs/common';
import { AppModule } from './app.module';

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

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

  await app.listen(3000);
}
bootstrap();

With defaultVersion set, any controller or route that doesn't explicitly opt into a version falls back to v1. That matters: it means adding versioning to an existing API doesn't require touching every existing controller.

Versioning at the controller and route level

Once versioning is enabled, you apply it with a version option on the @Controller decorator, or per-route when only part of a resource changes between versions.

// src/users/users.controller.ts
import { Controller, Get, Param } from '@nestjs/common';
import { UsersService } from './users.service';

@Controller({ path: 'users', version: '1' })
export class UsersControllerV1 {
  constructor(private readonly usersService: UsersService) {}

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

@Controller({ path: 'users', version: '2' })
export class UsersControllerV2 {
  constructor(private readonly usersService: UsersService) {}

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

The pattern I've settled on is: two separate controller classes only when the response shape or business logic genuinely diverges, sharing the same underlying service. The controller is a thin adapter over the domain logic, not a place to duplicate it. If v2 only renames a field, that's a mapping concern in the controller, not a reason to fork the service layer.

For a single route that changed inside an otherwise stable controller, per-route versioning avoids duplicating the whole class:

@Controller('orders')
export class OrdersController {
  @Get(':id')
  @Version('1')
  findOneLegacy(@Param('id') id: string) {
    // old shape, kept for backward compatibility
  }

  @Get(':id')
  @Version('2')
  findOne(@Param('id') id: string) {
    // current shape
  }

  @Get(':id/items')
  findItems(@Param('id') id: string) {
    // unversioned; unaffected by the v1/v2 split above
  }
}

NestJS also supports @Version(VERSION_NEUTRAL) for routes that should respond regardless of the version prefix, useful for health checks and other infrastructure endpoints that shouldn't be duplicated per version.

Deprecating and retiring old versions

Adding a version is the easy half. The part teams consistently underinvest in is retiring the old one, and that's where versioning either pays off or becomes permanent technical debt.

Two things make deprecation tractable: telling clients a version is going away, and knowing whether anyone is still using it.

For the first, a Deprecation and Sunset response header (per RFC 8594) on the old version's routes gives clients a machine-readable signal, on top of whatever you put in changelog emails or docs:

// src/common/interceptors/deprecation.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class DeprecationInterceptor implements NestInterceptor {
  constructor(private readonly sunsetDate: string) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const response = context.switchToHttp().getResponse();
    response.setHeader('Deprecation', 'true');
    response.setHeader('Sunset', this.sunsetDate);
    return next.handle().pipe(tap(() => undefined));
  }
}

Apply it with @UseInterceptors(new DeprecationInterceptor('Wed, 01 Jan 2026 00:00:00 GMT')) on the deprecated controller.

For the second, actual retirement, you need usage data, not a guess. Log the version dimension on every request (a simple structured log field or a metric tagged with the version) so you can answer "is anyone still calling v1" with data instead of hoping nobody complains after you remove it. I'd treat "zero traffic on this version for a full billing cycle" as the minimum bar before deleting code, not "it's been a while."

Common pitfalls

A few mistakes show up repeatedly once teams start versioning for real:

Versioning the whole API when only one resource changed. If /orders needs a breaking change and /users doesn't, don't bump a global version number and force every consumer to migrate every endpoint. Per-controller or per-route versioning exists precisely so a breaking change in one resource doesn't ripple across the whole surface.

Letting the version fork propagate into the database layer. The version split belongs at the API boundary: controllers, DTOs, response mapping. If your repository or query logic starts branching on API version, you've let a presentation concern leak into your domain layer, and it will compound every time you add a version.

No sunset plan. Adding v2 without a plan to retire v1 means you accumulate versions forever. Every version you keep alive adds more tests to maintain, more docs to update, and edge cases nobody remembers the reason for. Decide the sunset window when you ship the new version, not after.

Treating additive changes as breaking. If your team bumps the version for every new optional field, you'll churn through versions fast and train consumers to ignore version numbers because they change too often to mean anything. Reserve version bumps for actual contract breaks.

Key takeaways

  • Version only when you have clients you don't control (external consumers, mobile apps, partners); an internal API with one consumer you control rarely needs it.
  • URI versioning (`/v1/...`) is the most operationally boring choice, and boring is what you want for something every client depends on.
  • Version at the controller or route level, not globally, so a breaking change in one resource doesn't force a migration across the whole API.
  • Keep the version split at the API boundary; don't let it leak into services, repositories, or the database layer.
  • Plan the sunset of an old version when you ship the new one, and use real traffic data, not intuition, to decide when it's safe to delete.
  • Distinguish additive changes (safe, no new version needed) from breaking changes (rename, remove, retype) before reaching for a version bump.

Frequently asked questions

Do I need API versioning from day one of a new project?

No. If you're the only consumer of your own API, or all your clients deploy in lockstep with the backend, versioning adds overhead without benefit. Add it when an external or independently-deployed client first depends on your contract.

What's the difference between versioning and just adding new optional fields?

Optional additive changes (new fields, new endpoints) are backward compatible by definition and don't need a version bump. Versioning exists for changes that would break an existing client if you rolled them out without one: renamed fields, removed fields, changed types, changed status codes.

Should I version my GraphQL API the same way as REST?

Not directly. GraphQL's schema evolution model favors deprecating fields with the `@deprecated` directive and adding new fields alongside old ones, rather than URI or header versions. The underlying principle, additive-first and deprecate-before-remove, is the same; the mechanism differs.

How long should I keep an old API version alive?

Long enough for real usage on it to drop to zero, not an arbitrary calendar guess. Instrument version usage as a metric or log field, set a sunset date when you ship the replacement, and use actual traffic data to confirm it's safe before you delete the old routes.

Can I version just the request/response DTOs without duplicating the whole controller?

Yes, for small changes. Keep one controller and route, and use a versioned response mapper or serializer that transforms the shared domain result into the shape the requested version expects. Full controller duplication is worth it only when the logic itself, not just the shape, genuinely diverges between versions.

Does versioning affect how I document the API with Swagger?

Yes: each version needs its own OpenAPI document, since the DTOs and routes differ. `@nestjs/swagger` supports this by scanning per-version modules or controllers separately, so you'd typically generate `/v1/docs` and `/v2/docs` rather than merging both versions into one spec.

Related articles

DTOs and Validation Pipes — Aman Kumar Singh
Custom Pipes and Transformation — Aman Kumar Singh
Controllers and Routing — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.