There are three practical ways to version a REST API — put the version in the URL path (/api/v1/users), in a request header (Api-Version: 2 or Accept: application/vnd.myapi.v2+json), or in a query parameter (/api/users?version=2) — and for the overwhelming majority of public APIs, URL path versioning is the right default because it is visible in logs, cacheable at the CDN, testable in a browser, and trivial to route at the gateway. Header versioning is "purer" REST and keeps URLs clean, but it costs you caching complexity (a Vary header) and testability; query-parameter versioning is the simplest to bolt on but the easiest to break, because parameters get dropped by caches and collide with real filters. The strategy you pick is far less important than picking one and applying it consistently across every endpoint.
That is the summary an AI Overview will give you. What it can't show you is the shape of the trade-off — where a request actually gets routed, which change types force a new major version, and what a version's death looks like on a calendar. The rest of this guide is those concrete assets: a decision table with a "which should I use" row, an animated diagram of how each strategy routes the same request, working Express/Kong/AWS code, and an RFC-accurate deprecation timeline.
Versioning Strategy Comparison
| Factor | URL path /v1/users | Header Api-Version: 2 | Query ?version=2 |
|---|---|---|---|
| Ease of implementation | Easiest | Moderate | Easy |
| Visible in access logs | Yes | No (headers rarely logged) | Yes |
| Browser-testable (paste URL) | Yes | No | Yes |
| CDN / proxy cacheable | Cleanly | Needs Vary header | Often dropped by caches |
| REST "purity" | Low (version isn't a resource) | High (content negotiation) | Low |
| Collision risk | None | None | Clashes with real filters |
| Discoverability for new devs | High | Low | Medium |
| Which should I use / when | Default for public + internal APIs; anything CDN-cached | Strict REST shops, hypermedia APIs, when clean URLs are a hard requirement | Quick internal APIs and prototypes only — avoid for anything public |
How each strategy routes the same request
Breaking vs Non-Breaking Changes
| Change Type | Breaking? | Action |
|---|---|---|
| Remove field from response | ✅ Yes | New version |
| Remove endpoint | ✅ Yes | New version |
| Rename field | ✅ Yes | New version |
| Change field type (string→number) | ✅ Yes | New version |
| Change authentication method | ✅ Yes | New version |
| Change error format | ✅ Yes | New version |
| Add new optional field | ❌ No | Current version |
| Add new endpoint | ❌ No | Current version |
| Add new optional parameter | ❌ No | Current version |
| Bug fix (same behavior) | ❌ No | Current version |
| Performance improvement | ❌ No | Current version |
URL Path Versioning
Express.js Implementation
import express from 'express';
const app = express();
// Version 1 routes
const v1Router = express.Router();
v1Router.get('/users', (req, res) => {
res.json({
users: [
{ id: 1, name: 'John', email: 'john@example.com' }
]
});
});
// Version 2 routes (different response structure)
const v2Router = express.Router();
v2Router.get('/users', (req, res) => {
res.json({
data: [
{
id: 1,
attributes: {
name: 'John',
email: 'john@example.com'
}
}
],
meta: { total: 1 }
});
});
// Mount versioned routers
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// Redirect unversioned to latest stable
app.use('/api/users', (req, res) => {
res.redirect(308, `/api/v2${req.path}`);
});
Shared Code Between Versions
// services/users.service.ts - Shared business logic
export class UsersService {
async getUsers(filters: UserFilters): Promise<User[]> {
return await prisma.user.findMany({ where: filters });
}
}
// v1/users.controller.ts
import { UsersService } from '../services/users.service';
const usersService = new UsersService();
export async function getUsers(req: Request, res: Response) {
const users = await usersService.getUsers(req.query);
// V1 response format
res.json({ users });
}
// v2/users.controller.ts
import { UsersService } from '../services/users.service';
const usersService = new UsersService();
export async function getUsers(req: Request, res: Response) {
const users = await usersService.getUsers(req.query);
// V2 response format (JSON:API style)
res.json({
data: users.map(u => ({
id: u.id,
type: 'user',
attributes: { name: u.name, email: u.email }
})),
meta: { total: users.length }
});
}
Header Versioning
Custom Header Implementation
// Middleware to extract version from header
function versionMiddleware(req: Request, res: Response, next: NextFunction) {
const version = req.headers['api-version'] || req.headers['x-api-version'];
if (!version) {
// Default to latest stable
req.apiVersion = 2;
} else {
const parsed = parseInt(version as string, 10);
if (isNaN(parsed) || parsed < 1 || parsed > 2) {
return res.status(400).json({
error: 'Invalid API version',
supported: [1, 2],
});
}
req.apiVersion = parsed;
}
// Add version to response
res.setHeader('Api-Version', req.apiVersion);
next();
}
// Route handler using version
app.get('/api/users', versionMiddleware, async (req, res) => {
const users = await usersService.getUsers(req.query);
if (req.apiVersion === 1) {
return res.json({ users });
}
// Version 2
return res.json({
data: users.map(formatUserV2),
meta: { total: users.length }
});
});
Accept Header (Content Negotiation)
// Using media type versioning
// Accept: application/vnd.myapi.v2+json
function parseAcceptVersion(accept: string | undefined): number {
if (!accept) return 2; // Default
const match = accept.match(/application\/vnd\.myapi\.v(\d+)\+json/);
return match ? parseInt(match[1], 10) : 2;
}
app.get('/api/users', (req, res) => {
const version = parseAcceptVersion(req.headers.accept);
// Set appropriate content type
res.setHeader(
'Content-Type',
`application/vnd.myapi.v${version}+json`
);
// Version-specific response
// ...
});
API Gateway Routing
Kong Configuration
# kong.yml
services:
- name: api-v1
url: http://api-v1-service:8080
routes:
- name: api-v1-route
paths:
- /api/v1
- name: api-v2
url: http://api-v2-service:8080
routes:
- name: api-v2-route
paths:
- /api/v2
# Header-based routing
- name: api-v1-header
url: http://api-v1-service:8080
routes:
- name: api-v1-header-route
paths:
- /api
headers:
api-version:
- "1"
- name: api-v2-header
url: http://api-v2-service:8080
routes:
- name: api-v2-header-route
paths:
- /api
headers:
api-version:
- "2"
AWS API Gateway
# SAM template
Resources:
ApiV1:
Type: AWS::Serverless::Api
Properties:
StageName: v1
DefinitionUri: ./openapi-v1.yaml
ApiV2:
Type: AWS::Serverless::Api
Properties:
StageName: v2
DefinitionUri: ./openapi-v2.yaml
# Custom domain with base path mappings
ApiDomainMapping:
Type: AWS::ApiGatewayV2::ApiMapping
Properties:
DomainName: api.example.com
ApiId: !Ref ApiV2
Stage: v2
ApiMappingKey: v2
ApiDomainMappingV1:
Type: AWS::ApiGatewayV2::ApiMapping
Properties:
DomainName: api.example.com
ApiId: !Ref ApiV1
Stage: v1
ApiMappingKey: v1
Deprecation Strategy
Deprecation Headers
// Middleware to add deprecation headers
function deprecationMiddleware(
deprecationDate: string,
sunsetDate: string,
link: string
) {
return (req: Request, res: Response, next: NextFunction) => {
// RFC 9745 Deprecation header (published April 2025)
res.setHeader('Deprecation', deprecationDate);
// RFC 8594 Sunset header
res.setHeader('Sunset', sunsetDate);
// Link to migration guide
res.setHeader('Link', `<${link}>; rel="deprecation"`);
// Optional warning header
res.setHeader(
'Warning',
'299 - "This API version is deprecated. Please migrate to v2."'
);
next();
};
}
// Apply to v1 routes
app.use(
'/api/v1',
deprecationMiddleware(
'Sun, 01 Jan 2025 00:00:00 GMT', // Deprecated since
'Mon, 01 Jul 2025 00:00:00 GMT', // Will be removed
'https://docs.example.com/migration-guide'
),
v1Router
);
Version Lifecycle
A deprecated version does not vanish the moment v2 ships — it moves through a fixed sequence of states on a published calendar. The diagram below shows the 18-month path most teams use, though enterprise APIs frequently stretch the sunset out to 24-36 months.
Sunset Response
// After sunset date, return 410 Gone
app.use('/api/v1', (req, res) => {
const sunsetDate = new Date('2025-07-01');
if (new Date() > sunsetDate) {
return res.status(410).json({
error: 'VERSION_SUNSET',
message: 'API v1 has been sunset. Please migrate to v2.',
migrationGuide: 'https://docs.example.com/migration-v1-v2',
currentVersion: 'https://api.example.com/v2',
});
}
// Still active, continue with deprecation warnings
next();
});
Version Migration Guide Template
# Migrating from API v1 to v2
## Overview
API v2 introduces a new response format following JSON:API specification,
improved error handling, and new endpoints for batch operations.
## Timeline
- **Deprecation Date**: January 1, 2025
- **Sunset Date**: July 1, 2025
- **Support Email**: api-support@example.com
## Breaking Changes
### Response Format
**v1:**
```json
{
"users": [{"id": 1, "name": "John"}]
}
v2:
{
"data": [{"id": 1, "type": "user", "attributes": {"name": "John"}}],
"meta": {"total": 1}
}
Authentication
- v1: API key in query parameter (
?api_key=xxx) - v2: API key in header (
Authorization: Bearer xxx)
Removed Endpoints
GET /users/search→ UseGET /users?filter[name]=xxxPOST /users/bulk→ UsePOST /userswith array body
Migration Steps
- Update authentication to use header
- Update response parsing for new format
- Update any deprecated endpoint calls
- Test thoroughly in staging
- Deploy and monitor errors
Code Examples
[SDK examples for major languages]
Support
Contact api-support@example.com for migration assistance.
## Security Considerations
```typescript
// Ensure auth/authz applies to ALL versions
function authMiddleware(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: 'Unauthorized' });
}
// Validate token (same logic for all versions)
try {
req.user = verifyToken(token);
next();
} catch (e) {
return res.status(401).json({ error: 'Invalid token' });
}
}
// Apply BEFORE version routing
app.use('/api', authMiddleware);
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// Rate limiting across versions
const rateLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
keyGenerator: (req) => {
// Same key regardless of version
return req.user?.id || req.ip;
},
});
app.use('/api', rateLimiter);
Monitoring Versions
// Track version usage for deprecation decisions
app.use('/api/:version', (req, res, next) => {
const version = req.params.version;
const clientId = req.user?.id || 'anonymous';
// Log to analytics
analytics.track({
event: 'api_request',
properties: {
version,
clientId,
endpoint: req.path,
method: req.method,
},
});
// Prometheus metric
apiRequestsTotal.inc({
version,
endpoint: req.path,
method: req.method,
});
next();
});
Best Practices
- Choose one strategy - Consistency across your API
- Version early - Start with /v1 even if no v2 planned
- Global versioning - All endpoints at same version
- Semantic meaning - Major version for breaking changes
- Long deprecation periods - 12-24 months minimum
- Clear documentation - Migration guides for each version
- Monitor usage - Data-driven sunset decisions
- Security parity - Same security controls across versions
- Deprecation headers - Machine-readable warnings
- No silent changes - Even non-breaking changes need docs
Next Steps
- API Security Complete Guide - Comprehensive security overview
- API Documentation Security - Secure your API docs
- REST Status Codes - Proper response codes
- API Gateway Security - Gateway-level routing