Breaking API changes are the developer equivalent of pulling the tablecloth — everything falls. I've shipped three major versions of an ERP API while maintaining backward compatibility for clients that hadn't migrated. The tooling in NestJS makes versioning straightforward to implement, but the hard part is the migration strategy: how do you deprecate v1 without breaking integrations? This post covers the NestJS configuration, the controller patterns, and the process I use to communicate and manage API version transitions.
NestJS 8+ has native API versioning built into the framework, supporting four strategies: URI versioning (/v1/users, /v2/users), Header versioning (custom request header like X-API-Version), Media Type versioning (the Accept header's version parameter), and Custom versioning (any function that extracts a version from a request). You enable versioning in the bootstrap function with one line: `app.enableVersioning({ type: VersioningType.URI })`. After that, decorate controllers or individual routes with `@Version('1')` or `@Version('2')`.
URI versioning embeds the version in the URL path: `/api/v1/invoices`, `/api/v2/invoices`. It's the most visible and explicit strategy — clients know which version they're calling from the URL alone, logs are easy to filter by version, and API documentation tools (Swagger) can generate separate docs per version. The downside is that URLs change when you version — but for public APIs with clients you don't control, explicit URLs are clearer than hidden header negotiation.
Header versioning keeps URLs clean — `/api/invoices` regardless of version — and uses a custom header like `X-API-Version: 2` to select the version. This is preferred when you have many versions and don't want version numbers to clutter URLs. The downside: it's less discoverable, harder to test in a browser, and requires clients to set headers explicitly. I use URI versioning for external APIs and header versioning for internal service-to-service APIs where I control both sides.
NestJS API Versioning Strategies:
──────────────────────────────────────────────────────────
1. URI Versioning (recommended for public APIs)
GET /api/v1/invoices → InvoicesV1Controller
GET /api/v2/invoices → InvoicesV2Controller
✓ Visible, explicit, easy to test in browser
2. Header Versioning (for service-to-service)
GET /api/invoices
X-API-Version: 2 → InvoicesV2Controller
✓ Clean URLs, ✗ less discoverable
3. VERSION_NEUTRAL (for stable shared endpoints)
GET /api/health → responds to ANY version
GET /api/docs → responds to ANY version
Migration lifecycle:
Week 0: Ship v2 alongside v1 (both work)
Week 1-4: Document v1 as deprecated, send emails
Month 2: Add Sunset header to v1 responses
Month 3: Log v1 usage per API key/user
Month 5: Return 429 for v1 requests with migration link
Month 6: Remove v1 codeFrom my experience managing API versions in ERP projects: use VERSION_NEUTRAL for shared endpoints that don't change between versions. Rather than duplicating a route handler in every version controller, mark stable endpoints with `@Version(VERSION_NEUTRAL)` — they respond to requests regardless of what version the client specifies. A GET /api/health endpoint, for example, doesn't need v1/v2 variants. VERSION_NEUTRAL prevents duplication and ensures utility endpoints are always available.
The cleanest approach for versioned NestJS APIs is to keep business logic in a service layer and expose it via version-specific controllers. Version 1 and Version 2 controllers can share the same service, just calling different methods or passing different DTOs. This prevents the antipattern of duplicating business logic across version controllers. When v2 changes the request/response shape but not the underlying logic, only the controller and DTOs change — the service is untouched.
When using @nestjs/swagger with versioned APIs, configure a separate Swagger document per API version. Each DocumentBuilder gets its own base path — `/api/v1` for v1, `/api/v2` for v2. The `SwaggerModule.createDocument` call takes the app and a filtered set of routes (using the include option). This gives you separate /api-docs/v1 and /api-docs/v2 Swagger UIs. Clients on v1 see only v1 endpoints; clients migrating to v2 see the new contract.
// main.ts — enable URI versioning
import { VersioningType } from '@nestjs/common'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
app.enableVersioning({ type: VersioningType.URI })
await app.listen(3000)
}
// invoices-v1.controller.ts
import { Controller, Get, Version, VERSION_NEUTRAL } from '@nestjs/common'
@Controller({ path: 'invoices', version: '1' })
export class InvoicesV1Controller {
constructor(private readonly invoicesService: InvoicesService) {}
@Get()
findAll() {
return this.invoicesService.findAllV1() // old response shape
}
@Get('health')
@Version(VERSION_NEUTRAL) // responds to all versions
health() {
return { status: 'ok' }
}
}
// invoices-v2.controller.ts — extended with pagination
@Controller({ path: 'invoices', version: '2' })
export class InvoicesV2Controller {
constructor(private readonly invoicesService: InvoicesService) {}
@Get()
findAll(@Query('cursor') cursor?: string, @Query('limit') limit = 20) {
return this.invoicesService.findAllV2({ cursor, limit }) // cursor pagination
}
}
// Sunset header middleware for v1 deprecation
@Injectable()
export class DeprecationMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: () => void) {
if (req.url.includes('/v1/')) {
res.setHeader('Deprecation', 'true')
res.setHeader('Sunset', 'Sat, 01 Jan 2027 00:00:00 GMT')
res.setHeader('Link', '<https://api.example.com/v2/>; rel="successor-version"')
}
next()
}
}blog.posts.apiVersioningNestjs.content.section3Content
The biggest API versioning mistake is removing v1 routes before all clients have moved to v2. Even if you've communicated a deprecation date, integrations break. The safe approach: add `@Deprecated()` notes in v1 Swagger docs, add response headers like `Deprecation: true; date='2026-01-01'`, log usage of deprecated endpoints, and only remove them when usage drops to zero or after a guaranteed migration period (6+ months for external APIs). I track deprecated endpoint usage in Datadog and only remove when p95 usage has been zero for 30 days.
My API deprecation process: 1) Ship v2 alongside v1 — both fully functional. 2) Update API docs to mark v1 endpoints as deprecated with migration guides. 3) Add Sunset headers to v1 responses: `Sunset: Sat, 01 Jan 2027 00:00:00 GMT`. 4) Add logging to count v1 endpoint calls per client (API key or user ID). 5) Email clients who are still using v1 with a migration deadline. 6) A week before sunset, return 429 errors for v1 requests with a message pointing to v2. 7) Remove v1 code on the sunset date. This process typically takes 3-6 months for external APIs.
Internal service-to-service APIs within a monorepo or microservices system don't need the same versioning discipline as external APIs. If you control the producer and all consumers, you can use semantic versioning with coordinated deployments — upgrade the service and all its callers in the same deploy. External APIs (consumed by mobile apps, third-party integrations, or public developers) require strict versioning, long deprecation windows, and backward compatibility guarantees. Keep these two categories of APIs on separate versioning tracks.