🛠️ Developer Tools
· 5 min read

REST API Versioning Strategies — URL, Header, or Query Param? (2026)


Your REST API will evolve. Fields get renamed, endpoints get restructured, response shapes change. The moment external clients depend on your API, every breaking change becomes a coordination problem. Versioning gives you a contract: old clients keep working while new clients adopt the latest shape.

But how you version matters. There are four mainstream strategies, each with real trade-offs. This guide compares them with code, then covers deprecation and sunset practices so you can retire old versions cleanly.

1. URL Path Versioning

The version lives directly in the URL: /v1/users, /v2/users.

This is the most widely adopted strategy. Stripe, GitHub, and Google Cloud all use it. The version is impossible to miss — it’s right there in every request.

# Client request
GET /v2/users/42 HTTP/1.1
Host: api.example.com
// Express router setup
import v1Router from './routes/v1/users.js';
import v2Router from './routes/v2/users.js';

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

Pros: Dead simple to implement and test. Works in any HTTP client, browser, or curl command. Easy to route at the load balancer or gateway level. API docs stay unambiguous.

Cons: URLs change between versions, which breaks the REST principle that a resource should have one canonical URI. You end up duplicating route definitions, and clients must update URLs to migrate.

2. Custom Header Versioning

The version travels in a custom request header. The URL stays clean.

GET /users/42 HTTP/1.1
Host: api.example.com
Api-Version: 2
// Express middleware
app.use('/users', (req, res, next) => {
  const version = parseInt(req.headers['api-version']) || 1;
  req.apiVersion = version;
  next();
});

app.get('/users/:id', (req, res) => {
  if (req.apiVersion >= 2) return getUserV2(req, res);
  return getUserV1(req, res);
});

Pros: URLs remain stable and resource-oriented. You can version independently of the URL structure. Clean separation of concerns — the version is metadata, not part of the resource path.

Cons: Harder to test casually — you can’t just paste a URL into a browser. Requires clients to remember to set the header. Easy to forget in documentation. Some caching layers ignore custom headers, which can serve stale responses.

3. Query Parameter Versioning

The version is appended as a query string: /users/42?version=2.

GET /users/42?version=2 HTTP/1.1
Host: api.example.com
app.get('/users/:id', (req, res) => {
  const version = parseInt(req.query.version) || 1;
  if (version >= 2) return getUserV2(req, res);
  return getUserV1(req, res);
});

Pros: Easy to add to any existing API without restructuring routes. Simple to test — just append the parameter. No special client configuration needed.

Cons: Query parameters are typically for filtering and pagination, not versioning — this muddies their purpose. Caching becomes unreliable since ?version=1 and ?version=2 are different cache keys for the same resource. URLs look cluttered when combined with other parameters.

4. Content Negotiation (Accept Header)

The version is embedded in the Accept header’s media type. This is the most “RESTful” approach — the client negotiates the representation it wants.

GET /users/42 HTTP/1.1
Host: api.example.com
Accept: application/vnd.example.v2+json
app.get('/users/:id', (req, res) => {
  const accept = req.headers['accept'] || '';
  const match = accept.match(/application\/vnd\.example\.v(\d+)\+json/);
  const version = match ? parseInt(match[1]) : 1;

  if (version >= 2) return getUserV2(req, res);
  return getUserV1(req, res);
});

Pros: Fully aligned with HTTP content negotiation semantics. URLs stay completely clean. Allows versioning the representation without changing the resource identity — exactly what REST intends.

Cons: The most complex to implement and debug. Custom media types confuse many HTTP tools. Clients need to construct precise Accept headers. Documentation overhead is significant. Very few teams adopt this outside of large-scale public APIs.

Comparison Table

Strategy Visibility Cacheability REST Purity Ease of Use Best For
URL Path ⭐⭐⭐ Obvious ⭐⭐⭐ Excellent ⭐ Low ⭐⭐⭐ Easiest Public APIs, most teams
Custom Header ⭐ Hidden ⭐⭐ Needs Vary ⭐⭐ Medium ⭐⭐ Moderate Internal services, SDKs
Query Param ⭐⭐ Visible ⭐ Poor ⭐ Low ⭐⭐⭐ Easy Quick prototypes, legacy
Content Negotiation ⭐ Hidden ⭐⭐ Needs Vary ⭐⭐⭐ High ⭐ Complex Large public APIs, purists

If you’re unsure, start with URL path versioning. It’s what most developers expect, and it works well with API design best practices you’re likely already following.

Deprecation Strategy and Sunset Headers

Versioning is only half the problem. You also need a plan for retiring old versions. Running v1, v2, and v3 simultaneously is expensive — each version needs testing, monitoring, and security patches.

The Sunset Header

The Sunset HTTP header (RFC 8594) tells clients when a version will stop working:

HTTP/1.1 200 OK
Sunset: Sat, 01 Nov 2026 00:00:00 GMT
Deprecation: true
Link: <https://api.example.com/v3/docs>; rel="successor-version"

Start returning Sunset headers months before you actually remove a version. Pair it with a Deprecation header and a Link to the migration guide. This gives clients a machine-readable signal — not just a blog post they might miss.

Deprecation Timeline

A practical deprecation flow:

  1. Announce — Document the new version. Add Deprecation: true and Sunset headers to the old version’s responses.
  2. Monitor — Track traffic to the deprecated version. Reach out to high-volume consumers directly.
  3. Restrict — Stop accepting new API key registrations for the old version.
  4. Remove — After the sunset date, return 410 Gone with a body pointing to the new version.

Give external consumers at least 6–12 months. Internal services can move faster, but never surprise anyone.

When to Bump Major vs. Minor

Not every change needs a new version. Use semantic reasoning:

Bump the major version (v1 → v2) when you:

  • Remove or rename a field in the response
  • Change the type of an existing field
  • Remove an endpoint
  • Alter authentication requirements
  • Change error response structure

Keep the same version when you:

  • Add a new optional field to the response
  • Add a new endpoint
  • Add optional query parameters
  • Improve performance or fix bugs without changing the contract

The rule is simple: if existing clients would break, it’s a new major version. If they wouldn’t notice, it’s not.

Practical Recommendations

  • Pick one strategy and stick with it. Mixing URL versioning for some endpoints and header versioning for others creates confusion.
  • Default to the latest stable version when no version is specified, or return a 400 — just be consistent.
  • Version the API, not individual endpoints. If /v2/users exists, all endpoints should be available under /v2/.
  • Document every version. Maintain separate OpenAPI specs per version. Clients shouldn’t have to guess what changed.
  • Automate sunset monitoring. Alert when deprecated versions still receive significant traffic as the sunset date approaches.

A Note on API Gateways

If you’re running behind an API gateway (AWS API Gateway, Kong, Apigee), versioning gets easier regardless of strategy. Gateways can route /v1/* to one backend and /v2/* to another, or inspect headers and rewrite requests before they hit your service. This means you can run completely separate deployments per version — no branching logic in your application code. It’s worth the infrastructure investment once you’re managing more than two live versions.

API versioning isn’t glamorous, but it’s the difference between a smooth migration and a broken integration at 2 AM. Choose a strategy that matches your team’s size and your consumers’ expectations, document it clearly, and plan for retirement from day one.