f in x
API Versioning: URI, Header, and Strategies to Evolve Without Breaking Clients
> cd .. / HUB_EDITORIALE
Analisi dei dati e metriche

API Versioning: URI, Header, and Strategies to Evolve Without Breaking Clients

[2026-06-05] Author: Ing. Calogero Bono

Have you ever released a change to an API and discovered that existing clients broke? It's a classic. API versioning isn't just a technical procedure – it's a coexistence strategy with everyone using your service. Every time you add a field, change a format, or remove an endpoint, you risk crashing mobile apps, third-party integrations, or your own frontend.

At Meteora Web we work with REST and GraphQL APIs every day. We've seen projects with no versioning and total chaos, and projects where bad versioning multiplied complexity without solving the real problem. In this hands-on guide we'll show you the three main strategies – URI versioning, header versioning, and other alternatives – with concrete examples, pros and cons, and the logic to choose the right one.

Why version an API

API versioning serves one purpose: let you evolve the backend without forcing clients to change immediately. A mobile app doesn't update itself. A business partner won't reconfigure integration every week. The API version is a contract: as long as that contract is valid, the client works.

Without versioning, every change becomes breaking or freezes you. With bad versioning, you pay in complexity and maintenance. The right choice depends on your context: how much control you have over clients, how many clients, how fast you want to evolve.

Strategy 1: URI Versioning (most popular, but careful)

URI versioning embeds the version number directly in the URL path. Classic example: /api/v1/users and /api/v2/users.

How to implement

In Express.js, use separate routers per version:

const express = require('express');
const app = express();

const v1Router = express.Router();
v1Router.get('/users', (req, res) => { /* v1 logic */ });

const v2Router = express.Router();
v2Router.get('/users', (req, res) => { /* v2 logic */ });

app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);

app.listen(3000);

In Laravel, group routes with prefix and separate controllers:

Route::prefix('api/v1')->group(function () {
    Route::get('/users', [App\Http\Controllers\Api\V1\UserController::class, 'index']);
});

Route::prefix('api/v2')->group(function () {
    Route::get('/users', [App\Http\Controllers\Api\V2\UserController::class, 'index']);
});

Pros

  • Dead simple. Client sees version in URL, no ambiguity.
  • Cache-friendly. Proxies and CDNs treat different URLs as different resources.
  • Explorable. You can test different versions directly from the browser.

Cons

  • Multiplies URLs. Every endpoint exists in every version. Route count grows linearly.
  • Hard to deprecate. Once a public path exists, removing it is a breaking change.
  • Encourages laziness. It's easy to create a new version for every small change, piling up technical debt.

Strategy 2: Header Versioning (more elegant, less immediate)

With header versioning, the version is passed via an HTTP header, typically Accept or a custom header. The client specifies the version when requesting the resource, and the server responds accordingly.

Common implementation

Using the Accept header with custom media type (Content Negotiation):

GET /api/users
Accept: application/vnd.myapi.v1+json

Or a custom header like X-API-Version: 1 (less standard, but simpler).

In Express.js, read the header and route accordingly:

app.get('/api/users', (req, res) => {
  const version = req.headers['accept']?.includes('vnd.myapi.v1') ? 1 : 2;
  if (version === 1) { /* v1 response */ }
  else { /* v2 */ }
});

In Laravel, use middleware to modify the response based on Accept:

public function handle($request, Closure $next)
{
    $accept = $request->header('Accept');
    if (str_contains($accept, 'vnd.myapi.v1')) {
        // apply v1 transformations
    }
    return $next($request);
}

Pros

  • Clean URLs. Paths stay unchanged, no pollution.
  • Clear separation. Version concerns representation, not the resource.
  • Fewer routes to maintain if you handle logic at middleware or transformation layer.

Cons

  • Opaque to clients. Not obvious which version is used (must read docs).
  • Tricky with proxies that don't inspect custom headers.
  • Overkill for small projects. With 2 versions, URI versioning is enough.

Other strategies: Query Parameter, Media Type, and Hybrid

Query Parameter versioning

Add ?version=1 to the URL. Looks simple but we discourage it. Query params are often ignored by caches, create semantic ambiguity (the param could be part of data filtering), and are easy to forget. Use only in internal or temporary environments.

Pure Media Type versioning

A variant of header versioning using only Accept without changing the URL. The server responds with appropriate content. This is the most RESTful approach per some standards. It requires well-behaved clients and robust server-side negotiation.

Hybrid approach

We often adopt a combination: URI versioning for major versions (breaking changes) and header versioning for minor versions (non-breaking additions). For example: /api/v1/users is version 1, but if we add an optional field in the response, we signal it with header X-API-Minor: 2 and the client can decide whether to use it.

Another strategy we use in larger projects is per-endpoint versioning: don't version the entire API, only the endpoints that change. For the rest, maintain backward compatibility using optional fields.

How to choose? A practical method

There is no one-size-fits-all. At Meteora Web we evaluate three factors:

  1. Number of clients and control over them. Few internal apps you update yourself? URI versioning is overkill – feature flags and backward compatibility suffice. Thousands of external clients? Go for a robust header versioning or hybrid.
  2. Change frequency. Release every week? URI versioning will lead to hundreds of routes. Header versioning with automatic transformations is more sustainable.
  3. API ecosystem. Using an API Gateway (Kong, AWS API Gateway, Apigee)? Many natively support version negotiation. Leverage it.

Common mistakes we've seen (and how to avoid them)

  • Never deprecating old versions. Set a roadmap: after X months or Y calls, sunset the oldest version. Communicate the deadline well in advance.
  • Versioning internal details. Version concerns the public contract, not implementation. Never expose database version IDs.
  • Ignoring documentation. Every version must have separate docs. Swagger/OpenAPI supports multiple versions in the same file.

For a deeper look at structuring an API in Express.js, check our Express.js guide.

In a nutshell — what to do now

  1. Choose a primary strategy. For most projects, start with URI versioning. It's simple and understandable.
  2. Establish a deprecation policy. After 2 active versions, the oldest goes to sunset. Communicate via email and the Sunset header (RFC 8594).
  3. Document every version. Use OpenAPI with separate files for v1, v2, etc., or a tool like Stoplight.
  4. Test old versions. Write regression tests for every still-supported version. Never delete an endpoint without ensuring no one calls it.
  5. Consider a hybrid approach if you anticipate frequent evolution: URI for major, header for minor.

Versioning isn't an ornament – it's the difference between an API that evolves safely and a production disaster. We see it every day. If you want your backend to grow without breaking things, invest in a strategy now.

Sponsored Protocol

Ing. Calogero Bono

> AUTHOR_EXTRACTED

Ing. Calogero Bono

Co-founder di Meteora Web. Ingegnere informatico, sviluppo ecosistemi digitali ad alte prestazioni. AI, automazione, SEO tecnica e infrastrutture web. Scrivo di tecnologia per rendere complesso… semplice.

[ Read Full Dossier ]

Hai bisogno di applicare questa strategia?

Esegui il protocollo di contatto per iniziare un progetto con noi.

> INIZIA_PROGETTO

Sponsored

> MW_JOURNAL

> READ_ALL()