API versioning ensures backward compatibility while allowing evolution. The versioning strategy you choose affects URL structure, client implementation, and long-term maintenance.
Key Takeaways
- 1URL path versioning is most visible and cacheable
- 2Header versioning keeps URLs clean but is less discoverable
- 3Query parameter versioning is easy to implement but not RESTful
- 4Support multiple versions during deprecation periods
- 5Document version lifecycle and deprecation policies
“API versions should be incremented only for breaking changes. Additive, non-breaking changes should not require a new version, following the principle of minimal versioning.”
Versioning Strategies
Each versioning strategy has trade-offs in terms of visibility, cacheability, and implementation complexity. The table below compares the most common approaches used in production APIs.
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URL Path | /api/v2/users | Visible, cacheable | URL changes per version |
| Header | Accept-Version: 2 | Clean URLs | Less discoverable |
| Query Param | ?version=2 | Easy to add | Not RESTful |
| Content-Type | Accept: application/vnd.api.v2+json | Standards-based | Complex |
| Subdomain | v2.api.example.com | Complete isolation | Infrastructure overhead |
URL path versioning is the most popular choice because it is visible and easy to test. Header versioning keeps URLs clean but makes debugging harder. Let us look at each in detail.
URL Path Versioning
Path versioning puts the version directly in the URL, making it obvious which version you are calling. This approach is used by most major APIs including Stripe, Twilio, and GitHub.
// URL path versioning - most common approach
// /api/v1/users
// /api/v2/users
// Express router setup
const express = require('express');
const app = express();
// Version-specific routers
const v1Router = require('./routes/v1');
const v2Router = require('./routes/v2');
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// Default to latest version
app.use('/api', v2Router);
// Version detection middleware
function detectVersion(req, res, next) {
const pathMatch = req.path.match(/^\/api\/v(\d+)/);
req.apiVersion = pathMatch ? parseInt(pathMatch[1]) : 2; // Default to v2
next();
}
// Route handler with version awareness
app.get('/api/:version/users/:id', (req, res) => {
const version = req.params.version; // 'v1' or 'v2'
if (version === 'v1') {
return res.json(formatUserV1(user));
} else {
return res.json(formatUserV2(user));
}
});This setup mounts version-specific routers at /api/v1 and /api/v2. The unversioned /api path defaults to the latest version, making it easy for clients to upgrade.
Header versioning offers an alternative that keeps URLs cleaner. This approach is often preferred when you want to minimize visible versioning in documentation.
Header Versioning
With header versioning, clients specify their desired version in a request header rather than the URL. This keeps endpoints stable across versions but requires explicit header configuration.
// Header-based versioning
// Accept-Version: 2
// X-API-Version: 2
// Middleware to extract version from header
function versionFromHeader(req, res, next) {
const version = req.headers['accept-version'] ||
req.headers['x-api-version'] ||
'2'; // Default version
req.apiVersion = parseInt(version);
next();
}
// Content negotiation (GitHub style)
// Accept: application/vnd.myapi.v2+json
function versionFromAccept(req, res, next) {
const accept = req.headers['accept'] || '';
const match = accept.match(/application\/vnd\.myapi\.v(\d+)\+json/);
req.apiVersion = match ? parseInt(match[1]) : 2;
next();
}
// Client usage
fetch('/api/users', {
headers: {
'Accept': 'application/vnd.myapi.v2+json',
'Authorization': 'Bearer token'
}
});
// Version-aware response
app.get('/api/users', versionFromAccept, (req, res) => {
const users = getUsers();
res.set('Content-Type', `application/vnd.myapi.v${req.apiVersion}+json`);
res.json(formatUsers(users, req.apiVersion));
});The middleware extracts the version from either a custom header (Accept-Version) or the standard Accept header using media type versioning. The response sets the appropriate Content-Type to confirm the version used.
Query parameter versioning is the simplest to implement but is generally considered less RESTful. It can be useful for gradual migration or when you need flexibility.
Query Parameter Versioning
Adding a version query parameter is the quickest way to add versioning to an existing API. While not as clean as other approaches, it works well for internal APIs or during migration periods.
// Query parameter versioning
// /api/users?version=2
// /api/users?api-version=2
function versionFromQuery(req, res, next) {
const version = req.query.version ||
req.query['api-version'] ||
'2';
req.apiVersion = parseInt(version);
next();
}
// Client usage
fetch('/api/users?version=2')
.then(res => res.json());
// Convenience: Allow version in path OR query
function detectVersionFlexible(req, res, next) {
// Check path first
const pathMatch = req.path.match(/^\/api\/v(\d+)/);
if (pathMatch) {
req.apiVersion = parseInt(pathMatch[1]);
}
// Fall back to query
else if (req.query.version) {
req.apiVersion = parseInt(req.query.version);
}
// Default
else {
req.apiVersion = 2;
}
next();
}This flexible approach checks for versions in the URL path first, then falls back to query parameters. This lets you support both styles during a transition period.
Whatever versioning strategy you choose, you need to route requests to the appropriate handler code. Here is how to structure version-aware routes.
Version Routing
As your API evolves, you need to transform requests and responses between versions. This typically involves adapter functions that translate between old and new data formats.
// Next.js API routes with versioning
// pages/api/v1/users.js
// pages/api/v2/users.js
// Or with route groups (App Router)
// app/api/v1/users/route.ts
// app/api/v2/users/route.ts
// Shared logic with version adapters
// lib/api/users.js
export function getUsers(version) {
const users = db.users.findAll();
if (version === 1) {
return users.map(u => ({
id: u.id,
name: u.fullName, // v1 used 'name'
email: u.email
}));
}
// v2+ format
return users.map(u => ({
id: u.id,
firstName: u.firstName,
lastName: u.lastName,
email: u.email,
createdAt: u.createdAt
}));
}
// Request transformation for breaking changes
function transformV1Request(body) {
// v1 sent { name: 'John Doe' }
// v2 expects { firstName: 'John', lastName: 'Doe' }
if (body.name) {
const [firstName, ...rest] = body.name.split(' ');
return {
firstName,
lastName: rest.join(' ')
};
}
return body;
}The shared getUsers function handles version differences internally. Request transformers convert old request formats to the current internal format, keeping your core logic clean.
Eventually, old API versions need to be retired. A clear deprecation process gives clients time to migrate while reducing your maintenance burden.
Version Deprecation
Deprecating an API version requires clear communication and a reasonable transition period. Use HTTP headers to signal deprecation status and sunset dates to clients.
// Deprecation headers
function deprecationMiddleware(req, res, next) {
if (req.apiVersion === 1) {
res.set('Deprecation', 'true');
res.set('Sunset', 'Sat, 01 Jun 2026 00:00:00 GMT');
res.set('Link', '</api/v2>; rel="successor-version"');
// Log deprecated API usage
console.warn(`Deprecated v1 API called: ${req.path}`);
}
next();
}
// Version lifecycle
const VERSION_STATUS = {
1: { status: 'deprecated', sunset: '2026-06-01' },
2: { status: 'current' },
3: { status: 'beta' }
};
// Return version info in response
app.get('/api/version', (req, res) => {
res.json({
current: 2,
supported: [1, 2],
beta: [3],
deprecated: [1],
versions: VERSION_STATUS
});
});
// Block sunset versions
function blockSunsetVersions(req, res, next) {
const versionInfo = VERSION_STATUS[req.apiVersion];
if (versionInfo?.status === 'deprecated') {
const sunsetDate = new Date(versionInfo.sunset);
if (new Date() > sunsetDate) {
return res.status(410).json({
error: 'Gone',
message: `API v${req.apiVersion} has been sunset. Please upgrade to v2.`
});
}
}
next();
}The Deprecation and Sunset headers follow RFC standards for communicating API lifecycle. Requests to sunset versions return 410 Gone with a clear upgrade message.
Clear documentation of your versioning policy helps API consumers plan their integrations and migrations. Include lifecycle information in your API documentation.
Version Documentation
Documenting your versioning policy sets expectations for API consumers. Include which versions are supported, what constitutes a breaking change, and how long deprecated versions remain available.
# API Versioning Policy
## Current Versions
- v2 (current) - Full support
- v1 (deprecated) - Sunset: June 1, 2026
- v3 (beta) - Not for production
## Versioning Strategy
We use URL path versioning: /api/v{version}/
## Breaking Changes (require new version)
- Removing endpoints or fields
- Changing field types
- Changing authentication method
- Changing error response format
## Non-Breaking Changes (same version)
- Adding new endpoints
- Adding optional fields
- Adding new query parameters
- Improving performance
## Deprecation Timeline
1. New version released (v2)
2. Old version deprecated (v1) - 6 month notice
3. Deprecation headers added
4. Old version sunset - returns 410 Gone
## Migration Guide
See /docs/migration/v1-to-v2 for upgrade instructions.This policy template clearly defines what triggers new versions, how long old versions are supported, and where to find upgrade help. Transparent policies build trust with API consumers.