API Versioning Strategies — How We Handle Breaking Changes Without Breaking Client Integrations

We renamed a field. We thought it was housekeeping. It broke a client's integration at 2am, because to them our "housekeeping" was a contract change they never agreed to. That is the night we adopted real API versioning, and it is the reason this post exists.
The API versioning strategies that actually fit depend on who your consumers are and how much control they have over their release cycles. API versioning is one of those topics that sounds straightforward until you are actually maintaining three active versions of the same endpoint and trying to coordinate a deprecation without breaking any of the ten integrations depending on each one. Every SaaS team faces this eventually — the question is whether you design for it before or retrofit it under pressure.
This post covers the four main API versioning strategies we use across our SaaS projects — plus what counts as a breaking change, how we implement versioned controllers in NestJS, how we handle database migrations across versions, the deprecation process that keeps clients happy, and the decision framework for choosing your strategy.

API Versioning Strategies for Breaking Changes: Four Approaches
There are four mainstream approaches to API versioning, and each has different tradeoffs for maintainers and consumers. We have used all four across different projects, and our recommendation for most SaaS teams is the first one.
URL Path Versioning
URL path versioning embeds the version directly in the endpoint path: /api/v1/users, /api/v2/users. This is the most widely adopted strategy because it is immediately visible, easy to route at the reverse proxy or gateway layer, and requires no special client configuration beyond using the correct URL.
1// v1 endpoint
2GET /api/v1/users
3
4// v2 endpoint
5GET /api/v2/usersThe visibility is also the main limitation. Every version of every endpoint is a separate URL, which means clients must update URL strings to migrate between versions. Browser caches, CDN configurations, and API gateway routing rules all reference specific version paths. When a version is deprecated, every downstream system that hardcoded the URL must update.
URL versioning works best when the versioning unit is the entire API surface rather than individual endpoints. API gateways like Kong Gateway can centralise version-based routing so individual services remain agnostic to which version they serve. A clean v1-to-v2 migration where the whole contract changes is simpler to communicate than a system where some endpoints are on v1 and others on v3 because they changed at different times. For most SaaS products, this is the right starting point.
Header Versioning
Header versioning passes the requested API version in an HTTP header — either a custom header (API-Version: 2) or via content negotiation (Accept: application/vnd.yourapi.v2+json). The URL stays clean and does not encode version information, keeping resource identifiers stable across versions.
1// Header versioning
2GET /api/users
3API-Version: 2The cost is discoverability. A URL is self-documenting in a way that a header is not. When an integration partner copies an example request from documentation into a tool like Postman, missing the version header produces unexpected behaviour that is not immediately obvious from the URL alone. API gateways that route based on headers require more configuration than path-based routing, and caching infrastructure needs correct Vary header configuration to avoid serving the wrong version.
Header versioning is the right choice when URL cleanliness and stable resource identifiers are higher priorities than immediate discoverability — typically for well-documented developer-facing APIs with a technically sophisticated consumer base.
Query Parameter Versioning
Query parameter versioning appends the version to the URL as a query string: /api/users?version=2. It shares the discoverability of URL path versioning while keeping the base resource path stable, but introduces caching complications because most HTTP caches treat URLs with different query strings as different cache keys.
1// Query parameter versioning
2GET /api/users?version=2We generally advise against this approach for public APIs. The version identifier mixed into the query string alongside business parameters creates parsing ambiguity, and the approach feels less formal than path or header versioning. It can work for internal APIs where speed of implementation is prioritised over developer experience.
Date-Based Versioning
Date-based versioning, used most visibly by Stripe, assigns each version a calendar date string rather than an integer. A client pins to a specific date version such as 2024-06-01, and the API guarantees that the response shape for that date will not change, even as newer versions introduce breaking changes.
1// Date-based versioning (Stripe's approach)
2Stripe-Version: 2024-06-01Date-based versioning makes the deprecation timeline explicit: a version dated 2023-01-01 communicates its age clearly, which is useful when communicating sunset timelines. The operational cost is that the server must maintain transformation layers for every active date version, which accumulates over time. This approach is appropriate for high-traffic public APIs where the investment in multiple version transformations is justified by client relationship value.
Strategy Comparison
| Strategy | Best Suited For | Main Limitation |
|---|---|---|
| URL Path (/v1/, /v2/) | Public APIs with broad consumer base | URL proliferation |
| Header (API-Version) | Developer-focused APIs | Less discoverable |
| Query Parameter (?v=2) | Internal or rapid-iteration APIs | Caching complications |
| Date-Based (2024-06-01) | High-volume public APIs | Server must maintain transformation layers |
What Counts as a Breaking Change
Defining "breaking" is less obvious than it sounds, and getting alignment on this within your team prevents arguments later. We use a written definition that we share with every API consumer:
Breaking changes: removing a field from a response, renaming a field, changing a field's data type, removing an endpoint, changing an endpoint's URL, changing the authentication mechanism, or altering the meaning of an error code.
Non-breaking changes: adding a new field to a response (existing consumers should ignore unknown fields), adding a new endpoint, adding an optional request parameter, or adding a new error code.
The gray area is changes that are technically non-breaking but behaviourally significant — changing the default sort order of a list endpoint, adding pagination to an endpoint that previously returned all results, or modifying rate limits. These do not break the API contract in a strict sense, but they can break consumer applications that depended on the old behaviour.
Our rule of thumb: if a consumer's code could behave differently after the change without the consumer modifying their application, treat it as a breaking change and version it accordingly.
NestJS Implementation of Versioned Controllers
The cleanest implementation pattern we have found uses a router-level version prefix with version-specific controllers that delegate to shared business logic. Here is how we structure it in NestJS:

1// app.module.ts — set up global version prefix
2import { Module } from '@nestjs/common';
3import { RouterModule } from '@nestjs/core';
4
5@Module({
6 imports: [
7 RouterModule.register([
8 {
9 path: 'api/v1',
10 module: V1Module,
11 },
12 {
13 path: 'api/v2',
14 module: V2Module,
15 },
16 ]),
17 ],
18})
19export class AppModule {}1// v1/users.controller.ts
2@Controller('users')
3export class V1UsersController {
4 constructor(private readonly userService: UserService) {}
5
6 @Get(':id')
7 async getUser(@Param('id') id: string) {
8 const user = await this.userService.findById(id);
9 return {
10 id: user.id,
11 name: user.fullName,
12 email: user.email,
13 };
14 }
15}1// v2/users.controller.ts — v2 adds profileUrl field
2@Controller('users')
3export class V2UsersController {
4 constructor(private readonly userService: UserService) {}
5
6 @Get(':id')
7 async getUser(@Param('id') id: string) {
8 const user = await this.userService.findById(id);
9 return {
10 id: user.id,
11 name: user.fullName,
12 email: user.email,
13 profileUrl: `/users/${user.id}/avatar`,
14 };
15 }
16}The critical architectural decision is keeping business logic version-agnostic. Your domain layer — the code that processes data, enforces rules, and interacts with the database — should know nothing about API versions. Versioned controllers translate between the external contract and the internal domain. A new API version requires new controllers and request/response types but no changes to business logic.
1// shared/user.service.ts — version-agnostic domain logic
2@Injectable()
3export class UserService {
4 constructor(
5 @InjectRepository(User)
6 private readonly userRepo: Repository<User>,
7 ) {}
8
9 async findById(id: string): Promise<User> {
10 return this.userRepo.findOneBy({ id });
11 }
12}For type safety across versions, define separate DTOs per version:
1// v1/dto/user-response.dto.ts
2export class V1UserResponse {
3 id: string;
4 name: string;
5 email: string;
6}
7
8// v2/dto/user-response.dto.ts
9export class V2UserResponse {
10 id: string;
11 name: string;
12 email: string;
13 profileUrl: string;
14}This pattern keeps the version surface clean and makes it obvious what changed between versions by comparing the DTOs side by side.
Database Schema Changes Across API Versions
Database schema changes during an API version migration are where most versioning strategies break down in practice. The database has no concept of API versions — it holds one schema at a time. The challenge is evolving that schema while multiple API versions depend on it.
We use the expand-contract pattern for database changes during API version migrations:
1// Phase 1: Expand — add new columns alongside existing ones
2// Migration file
3await queryRunner.addColumn('users', new TableColumn({
4 name: 'display_name',
5 type: 'varchar',
6 isNullable: true, // must be nullable initially
7}));
8
9// Phase 2: Backfill — populate the new column in batches
10await queryRunner.query(`
11 UPDATE users
12 SET display_name = CONCAT(first_name, ' ', last_name)
13 WHERE display_name IS NULL
14`);
15
16// Phase 3: Migrate API consumers to v2 (which uses display_name)
17// v1 still returns first_name + last_name via its controller
18// v2 returns display_name
19
20// Phase 4: Contract — once all consumers are on v2, drop old columns
21await queryRunner.dropColumn('users', 'first_name');
22await queryRunner.dropColumn('users', 'last_name');Phase 3 is where the versioning pays off. The v1 controller continues to return first_name and last_name from the database even after display_name is populated, because that is the contract v1 consumers depend on. The v2 controller returns display_name instead. Both read from the same database row with the same columns. No consumer breaks.
For more complex schema changes where columns are renamed or restructured, maintain database views per API version:
1// Create a v1 view that maps old column names
2await queryRunner.query(`
3 CREATE VIEW users_v1 AS
4 SELECT id, first_name, last_name, email, created_at
5 FROM users
6`);
7
8// V1 controller queries the view
9// V2 controller queries the table directlyThe view acts as a compatibility layer that lives in the database and requires no application code changes for existing versions.
Deprecation Policy
A versioning strategy without a deprecation policy leads to indefinite maintenance of old versions. Every active version is code you maintain, test, and operate. The operational cost accumulates.
Our deprecation process has four stages:
1. Announcement. Announce deprecation through multiple channels simultaneously: API documentation updates, email to registered developers, a changelog entry, and a blog post if the change is significant. The minimum notice period is 90 days for internal APIs and 6 months for public APIs serving enterprise clients.
2. Machine-readable signals. Every response from a deprecated API version includes Deprecation and Sunset HTTP headers, standardised in RFC 8594:
1HTTP/1.1 200 OK
2Deprecation: true
3Sunset: Sat, 20 Dec 2026 00:00:00 GMT
4Link: </api/v2/users>; rel="successor"
5
6{
7 "data": [...],
8 "deprecation": {
9 "sunset": "2026-12-20T00:00:00Z",
10 "migrationGuide": "/docs/migrate-v1-to-v2",
11 "suggestedVersion": "v2"
12 }
13}These headers allow API clients and monitoring tools to detect deprecated version usage programmatically, which is significantly more reliable than expecting developers to read changelog entries.
3. Migration support. Provide a migration guide with specific before-and-after examples for every breaking change. If the new version introduces significant restructuring, include a code snippet that translates a v1 request into the equivalent v2 request. For high-volume public APIs, provide a compatibility shim that accepts v1-shaped requests and translates them server-side to v2 for a defined interim period.
4. Hard sunset. Set and hold a firm sunset date. After that date, return 410 Gone for the deprecated version — not 404 or 500. A 410 response communicates that the resource is intentionally gone, which produces a clearer error message in client-side error reporting than a 404 would. Extending the sunset date repeatedly is the most common failure mode in API deprecation — each extension communicates to all clients that deadlines are not real.
1// NestJS exception filter for deprecated versions
2@Catch(VersionGoneException)
3export class VersionGoneFilter implements ExceptionFilter {
4 catch(exception: VersionGoneException, host: ArgumentsHost) {
5 const ctx = host.switchToHttp();
6 const response = ctx.getResponse<Response>();
7
8 response.status(410).json({
9 title: 'Version Deprecated',
10 status: 410,
11 detail: 'API version v1 was sunset on 2026-12-20. Please migrate to v2.',
12 migrationGuide: '/docs/migrate-v1-to-v2',
13 });
14 }
15}Running Two Versions Simultaneously
Running two API versions simultaneously is more straightforward than most teams expect, provided you have kept business logic separate from request handling. The pattern is simple:
- Version-specific controllers handle request parsing and response shaping
- A shared service layer handles business logic
- Routing dispatches to the correct controller based on the version prefix
- Database migrations use expand-contract so both versions can read the same data
The complexity comes from testing. Every combination of API version and supported feature must be tested. We maintain a test matrix that runs the full test suite against every active version:
1// Test both versions
2describe('GET /users/:id', () => {
3 describe('v1', () => {
4 it('returns id, name, email', async () => {
5 const response = await request(app)
6 .get('/api/v1/users/test-id')
7 .expect(200);
8
9 expect(response.body).toHaveProperty('id');
10 expect(response.body).toHaveProperty('name');
11 expect(response.body).toHaveProperty('email');
12 expect(response.body).not.toHaveProperty('profileUrl');
13 });
14 });
15
16 describe('v2', () => {
17 it('returns id, name, email, profileUrl', async () => {
18 const response = await request(app)
19 .get('/api/v2/users/test-id')
20 .expect(200);
21
22 expect(response.body).toHaveProperty('profileUrl');
23 });
24 });
25});
Choosing the Right Strategy For Your Context
After working through the strategies, implementation patterns, and deprecation mechanics, most teams face the same decision. Here is the framework we use:
| Your Situation | Recommended Strategy |
|---|---|
| Public API with external developers and mobile clients | URL path versioning with 6-month sunset window |
| Internal API consumed by teams on similar cadences | Semantic versioning via OpenAPI spec with consumer-driven contract testing |
| Developer-focused API with sophisticated consumers | Header versioning with RFC 8594 Deprecation and Sunset headers |
| High-volume public API needing precise compatibility | Date-based versioning with transformation layers per version |
The most important constraint is to choose one strategy and apply it consistently. A system where some endpoints use URL versioning and others use header versioning forces clients to implement two different version resolution patterns. Consistency in the versioning surface is itself a form of client experience.
We have covered the NestJS-specific implementation patterns for versioning in more detail in our post on REST API design mistakes for SaaS, which covers the full set of conventions we apply across every project. And if you are building a public API that needs authentication, our NestJS API key authentication guide covers the auth layer that pairs with whatever versioning strategy you choose.
Putting It Together
The field rename that broke a client's integration at 2am was the expensive lesson that taught us everything in this post about the API versioning strategies teams should adopt before they need them. The fix was not complicated — it was adding /v1/ to the URL, defining what counted as breaking, and building a deprecation timeline that gave consumers time to migrate.
API versioning is not hard. It just requires deciding on a strategy before you need it, keeping business logic separate from request handling, planning database schema changes with the expand-contract pattern, and communicating deprecations clearly with enough lead time. Every team that skips these steps eventually has a version of the 2am callback — and every team that does them ends up wondering what they were worried about.
If you are staring at a breaking change right now and wondering how to ship it without breaking your existing integrations, start with the expand-contract pattern on the database, add the new version prefix to your routes, and give your consumers a concrete sunset date. Everything else follows from those three decisions.
Frequently Asked Questions
URL path versioning (/api/v1/users) is the most practical choice for most SaaS products. It is explicit, easy to route, works with every HTTP client, and makes documentation straightforward. Header versioning is better for developer-focused APIs with sophisticated consumers. Date-based versioning (Stripe's approach) works best for high-volume public APIs needing precise compatibility guarantees.
Breaking changes include removing or renaming a field, changing a field's data type, removing an endpoint, changing an endpoint's URL, altering the authentication mechanism, or modifying the meaning of an error code. Adding optional fields, adding new endpoints, or adding optional request parameters are non-breaking. The gray area includes changes like modifying default sort order or adding pagination — treat these as breaking if consumer code could behave differently without modification.
Support old versions for a minimum of 90 days for internal APIs and 6 months or more for public APIs serving enterprise clients. Announce deprecation through multiple channels, include Sunset and Deprecation HTTP headers in responses, provide migration guides, and set a firm sunset date. Monitor usage of deprecated versions so you can proactively contact heavy consumers before the deadline.
Use versioned controllers that handle request parsing and response shaping per version, but share a common business logic layer. Keep domain logic version-agnostic. Route requests based on the version prefix at the router or API gateway level. This lets you add a new version with new controllers while existing versions continue unchanged.
Use the expand-contract pattern: add new columns while keeping old ones, backfill data in batches, deploy the new API version, and only drop old columns once all consumers have migrated. Alternatively, maintain separate database views per API version that map to the underlying schema. Never change a column that a live API version depends on without a migration plan.
Use date-based version strings (2026-06-01) or simple integers (v1, v2) for API versions — not semver. Semver communicates minor vs patch changes that are meaningless to API consumers. A consumer only needs to know if the version they are calling is stable or has changed. Use semver internally for tracking but expose simple version identifiers to consumers.
