Saikat
← Back to blog

API Versioning Strategies That Don't Break Clients

July 19, 2026·9 min read
API DesignRESTVersioningBackend
Share:
API Versioning Strategies That Don't Break Clients

The first time I broke a client in production, it wasn't with some dramatic rewrite. I renamed a response field from created to createdAt — a cleanup so obviously correct it didn't feel like a change at all. A partner's integration parsed that field, silently started reading undefined, and their invoicing job wrote wrong dates for two days before anyone noticed. That's the entire case for API versioning in one anecdote: the moment an API has a consumer you don't control, every response field is a promise, and versioning is how you change your mind without breaking promises.

But versioning is also expensive — every live version is a codepath you maintain, test, document, and eventually have to kill. So the interesting questions aren't "should I version?" but where the version lives, what actually forces a new one, and how a version dies. Most articles cover the first question and skip the other two, which is backwards: picking URI vs header versioning takes ten minutes, while a bad deprecation story haunts you for years.

The four places a version can live

There are four mainstream strategies, and each one is really a decision about which part of the request carries the contract.

URI versioning puts it in the path: GET /v1/orders. This is what Stripe, Twilio, and most public APIs do, because it's impossible to misunderstand. The version is visible in every log line, every browser address bar, every curl command, and every CDN cache key — a cached /v1/orders can never be served to a /v2 client. Purists object that /v1/orders/42 and /v2/orders/42 are the same resource with two names, which violates the resource-identity ideal I dug into in REST API vs RESTful API. They're right, and it almost never matters in practice.

Header versioning uses a custom header: Accept-Version: 2. URIs stay clean and stable, and version becomes negotiable metadata, which is the philosophically tidy answer. The operational reality: the version disappears from URLs and access logs unless you explicitly log the header, caches must be taught Vary: Accept-Version or they will happily serve v1 bodies to v2 clients, and every "quick test" now requires setting a header. Also decide up front what a request with no header gets — defaulting to "latest" is a trap, because it re-breaks every client that never opted in.

Media-type versioning (a.k.a. content negotiation) embeds the version in the Accept header: Accept: application/json;v=2 or GitHub's older application/vnd.github.v3+json. It's the most technically correct HTTP — you're literally negotiating the representation — and the most hostile to humans. In my experience it survives only in ecosystems with excellent client libraries that hide it completely; notably, GitHub itself moved to a plain X-GitHub-Api-Version header.

Query-param versioningGET /orders?version=2 — is the strategy everyone lists and nobody should pick. Query params get dropped by naive URL manipulation, interact messily with caching and with params that mean something, and signal "we bolted this on." Its one legitimate niche is date-based pinning on top of another scheme, the way Stripe uses account-pinned API dates.

Strategy Example Visible in logs/URLs Cache/CDN behavior Client ergonomics The real cost
URI GET /v1/orders Yes, everywhere Perfect — version is in the cache key curl-able, obvious "Ugly" URIs; resource identity split across versions
Header Accept-Version: 2 Only if you log it Must add Vary, easy to get wrong Extra header on every call Invisible version; default-version ambiguity
Media type Accept: application/json;v=2 No Must Vary: Accept Painful without a client library Team confusion; hardest to debug
Query param /orders?version=2 Yes Messy — params fight cache keys Easy but fragile Dropped params silently change contract

My default is boring: URI versioning for public APIs, because discoverability and cache-correctness beat elegance; header versioning for internal APIs where I control every client and want stable URIs across versions.

NestJS has this built in

NestJS supports three of the four natively, so the mechanical part costs almost nothing:

// main.ts
app.enableVersioning({
  type: VersioningType.URI,          // /v1/orders, /v2/orders
  defaultVersion: '1',
});

// or:
app.enableVersioning({
  type: VersioningType.HEADER,
  header: 'Accept-Version',
});

// or:
app.enableVersioning({
  type: VersioningType.MEDIA_TYPE,
  key: 'v=',                          // Accept: application/json;v=2
});

Versions then attach at the controller or route level, which is where this gets genuinely pleasant — both versions coexist in the same codebase with no routing gymnastics:

@Controller({ path: 'orders', version: '1' })
export class OrdersV1Controller {
  @Get()
  findAll(): Promise<OrderV1Dto[]> { /* legacy shape */ }
}

@Controller({ path: 'orders', version: '2' })
export class OrdersV2Controller {
  @Get()
  findAll(@Query() query: ListOrdersDto): Promise<Paginated<OrderV2Dto>> { /* new shape */ }

  @Version('2')
  @Get('stats')  // exists only in v2
  stats(): Promise<OrderStatsDto> { /* ... */ }
}

VERSION_NEUTRAL marks routes that answer any version — health checks, webhooks — and a route can declare version: ['1', '2'] when it's unchanged between them. One structural tip: keep version-specific code confined to controllers and DTO mappers, and let services stay versionless. The moment OrdersService knows about v1 vs v2, every future version multiplies your business-logic surface instead of your (thin) presentation surface.

What actually counts as a breaking change

Half the versioning pain I've seen comes from teams either versioning too eagerly (a new v3 for an additive field) or too casually ("it's just a rename"). The dividing line is precise:

Breaking — requires a new version: removing or renaming a field; changing a field's type or format ("42"42, epoch → ISO 8601); making an optional request field required; tightening validation on existing inputs; changing the meaning of a status code or error shape; changing default sort order or default page size that clients demonstrably rely on; removing an enum value you emit.

Not breaking — ship it today: adding a response field; adding an optional request field; adding a new endpoint; adding a new enum value you accept; relaxing validation; performance improvements.

The uncomfortable footnote is Hyrum's Law: with enough clients, someone depends on behavior you never promised — response field ordering, an undocumented error string. You can't version around that. You can only shrink the observable surface (document less, return less) and be explicit that anything undocumented is fair game. Contract-first stacks make the line mechanical rather than social — a gRPC .proto change that reuses a field number simply fails to compile downstream, as I covered in HTTP vs gRPC — but with JSON over HTTP, the discipline is on you.

The best version is the one you never ship

Before reaching for v2, exhaust additive evolution — changing the API by only ever adding. New field alongside the old one (createdAt next to created, both populated, old one documented as deprecated). New endpoint alongside the old (/orders/search rather than changing /orders filtering semantics). Expansion parameters (?include=customer) rather than changing the default response shape.

Two rules make this sustainable. Clients must treat unknown fields as ignorable — say so loudly in your docs, because a client that fails on unexpected fields has made your additive change breaking. And the server must be conservative in what it emits, never removing or reshaping without a version bump — the classic robustness principle, applied asymmetrically in your favor.

Stripe is the proof this scales: a decade-plus of aggressive product evolution on /v1 URLs, because breaking changes are absorbed by date-pinned transformation layers rather than URL bumps. Most of us don't need their machinery; we just need their instinct — versioning is the last resort, not the first move.

Deprecation: how a version dies without taking clients with it

Shipping v2 is the easy half. The workflow that's worked for me:

  1. Announce with a date, in-band. Changelogs get read by nobody at the right time. The API itself should say it — the Sunset header exists exactly for this, alongside a Deprecation signal and a pointer to the successor:
@Injectable()
export class SunsetInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler) {
    const res = ctx.switchToHttp().getResponse<Response>();
    res.setHeader('Deprecation', 'true');
    res.setHeader('Sunset', 'Sun, 01 Nov 2026 00:00:00 GMT');
    res.setHeader('Link', '</v2/orders>; rel="successor-version"');
    return next.handle();
  }
}
  1. Measure who's still on v1. Tag metrics with the resolved version and, for authenticated APIs, per API key. A deprecation with telemetry is a migration plan; without it, it's a prayer.
  2. Contact the stragglers directly. In every deprecation I've run, 80% of clients migrate from the announcement and the last 20% migrate from a personal email. Budget for that outreach.
  3. Brown-out before shutdown. Serve v1 a 410 Gone (or scheduled failures) for an hour in the weeks before the real date. It converts "we'll get to it" into a ticket in the laggard's sprint, on your schedule instead of during the final cutover.
  4. Keep the timeline honest. Public APIs: 6–12 months minimum. Internal: a few sprints, enforced. An open-ended deprecation is just two APIs forever.

Versioning below the HTTP layer

The version prefix is only the visible tip. Two deeper layers decide whether v1-and-v2-in-parallel is workable:

The database. Running two API versions over one schema means the schema must satisfy both, which is why DB migrations follow expand/contract: add the new column, dual-write, backfill, and only drop the old column after v1 is sunset — not merely deprecated. The contract step is gated on the API deprecation completing, and your migration tooling won't know that; you have to sequence it.

The event contracts. If services publish domain events, those are an API too, with subscribers you may not know about — the same versioned-contract discipline applies to every message on the broker, which is exactly the schema-evolution problem that shows up in event-driven NestJS architectures. An HTTP version bump that silently changes event payloads breaks consumers you never listed in the migration plan.

Wrapping up

Where the version lives — URI, header, media type — is the most debated and least consequential choice; pick URI for public, header for internal, and stop arguing. The choices that actually protect clients are quieter: a strict, shared definition of "breaking"; additive evolution as the default so versions stay rare; deprecation as machine-readable headers, telemetry, and enforced dates rather than a changelog entry; and remembering that your database schema and event contracts version on their own, slower clock.

Do that, and v2 stops being a dreaded event and becomes what it should be: a rare, boring, well-lit migration — announced in-band, measured continuously, and finished on schedule.