APIs evolve. Features are added, behaviors change, and sometimes breaking changes are necessary. A good versioning strategy lets you evolve your API while maintaining backward compatibility for existing clients.
Why Version APIs?#
Problems Without Versioning#
- Breaking changes affect all clients immediately
- No way to deprecate features gradually
- Clients forced to update on your schedule
- Rollback is difficult
- Can't support multiple client versions
Versioning Goals#
✓ Backward compatibility
✓ Clear deprecation path
✓ Parallel version support
✓ Minimal client disruption
✓ Clean evolution path
Versioning Approaches#
1. URL Path Versioning#
GET /v1/users
GET /v2/users
Implementation:
1// Express
2const v1Router = require('./routes/v1');
3const v2Router = require('./routes/v2');
4
5app.use('/v1', v1Router);
6app.use('/v2', v2Router);
7
8// Or with Next.js
9// app/api/v1/users/route.ts
10// app/api/v2/users/route.tsPros:
- Simple and obvious
- Easy to test and debug
- Clear in documentation
- Works with caching
Cons:
- URL changes between versions
- Can lead to code duplication
- Harder to share logic
2. Query Parameter Versioning#
GET /users?version=1
GET /users?version=2
Implementation:
1app.get('/users', (req, res) => {
2 const version = req.query.version || '1';
3
4 if (version === '2') {
5 return handleV2Users(req, res);
6 }
7 return handleV1Users(req, res);
8});Pros:
- URL structure stays consistent
- Optional (can default to latest)
- Easy to add to existing APIs
Cons:
- Less discoverable
- Can be forgotten in requests
- Caching more complex
3. Header Versioning#
GET /users
Accept-Version: v1
GET /users
Accept-Version: v2
Implementation:
1app.get('/users', (req, res) => {
2 const version = req.headers['accept-version'] || 'v1';
3
4 switch (version) {
5 case 'v2':
6 return handleV2Users(req, res);
7 default:
8 return handleV1Users(req, res);
9 }
10});Pros:
- Clean URLs
- RESTful (URL represents resource)
- Flexible
Cons:
- Hidden from URL
- Harder to test in browser
- Documentation complexity
4. Content Negotiation#
GET /users
Accept: application/vnd.myapi.v1+json
GET /users
Accept: application/vnd.myapi.v2+json
Implementation:
1app.get('/users', (req, res) => {
2 const accept = req.headers['accept'] || '';
3
4 if (accept.includes('vnd.myapi.v2')) {
5 res.type('application/vnd.myapi.v2+json');
6 return handleV2Users(req, res);
7 }
8
9 res.type('application/vnd.myapi.v1+json');
10 return handleV1Users(req, res);
11});Pros:
- Most RESTful approach
- Clean URLs
- Standard HTTP mechanism
Cons:
- Complex Accept header parsing
- Less intuitive
- Harder to test
Version Management#
Supporting Multiple Versions#
1// Shared logic with version-specific transformations
2async function getUsers(version) {
3 const users = await db.users.findMany();
4
5 if (version === 'v2') {
6 // V2 format: nested profile object
7 return users.map(user => ({
8 id: user.id,
9 email: user.email,
10 profile: {
11 name: user.name,
12 avatar: user.avatarUrl,
13 },
14 }));
15 }
16
17 // V1 format: flat structure
18 return users.map(user => ({
19 id: user.id,
20 email: user.email,
21 name: user.name,
22 avatar_url: user.avatarUrl,
23 }));
24}Deprecation Process#
Timeline:
1. Announce deprecation (6 months notice)
2. Add deprecation headers
3. Monitor v1 usage
4. Send reminders to active v1 users
5. Sunset v1
1// Add deprecation headers
2app.use('/v1', (req, res, next) => {
3 res.set('Deprecation', 'true');
4 res.set('Sunset', 'Sat, 31 Dec 2024 23:59:59 GMT');
5 res.set('Link', '</v2>; rel="successor-version"');
6 next();
7});Version Lifecycle#
Development → Current → Deprecated → Sunset
Typical timeline:
- Development: Pre-release, breaking changes allowed
- Current: Stable, primary version
- Deprecated: Maintained, migration encouraged (6-12 months)
- Sunset: No longer available
Breaking vs Non-Breaking Changes#
Non-Breaking (Safe)#
✓ Adding new endpoints
✓ Adding optional request fields
✓ Adding response fields
✓ Adding new enum values (with care)
✓ Relaxing validation
Breaking (Requires New Version)#
✗ Removing endpoints
✗ Removing or renaming fields
✗ Changing field types
✗ Changing error formats
✗ Tightening validation
✗ Changing authentication
Additive Changes#
1// V1 response
2{
3 "id": 1,
4 "name": "John"
5}
6
7// V1 response after additive change (backward compatible)
8{
9 "id": 1,
10 "name": "John",
11 "email": "john@example.com" // New field, clients ignore unknown fields
12}Documentation#
Version-Specific Docs#
1# OpenAPI with versioning
2openapi: 3.0.0
3info:
4 title: My API
5 version: 2.0.0
6
7servers:
8 - url: https://api.example.com/v2
9 description: Version 2 (Current)
10 - url: https://api.example.com/v1
11 description: Version 1 (Deprecated)Migration Guides#
1# Migrating from v1 to v2
2
3## Breaking Changes
4
5### User Response Format
6
7**Before (v1):**
8```json
9{
10 "id": 1,
11 "name": "John",
12 "avatar_url": "https://..."
13}After (v2):
1{
2 "id": 1,
3 "email": "john@example.com",
4 "profile": {
5 "name": "John",
6 "avatar": "https://..."
7 }
8}Migration Steps#
- Update response parsing to handle nested profile
- Rename
avatar_urltoprofile.avatar - Note:
namemoved toprofile.name
## Client Libraries
### Version-Aware SDKs
```typescript
// SDK supports multiple versions
import { createClient } from '@myapi/sdk';
const v1Client = createClient({ version: 'v1' });
const v2Client = createClient({ version: 'v2' });
// Or auto-detect
const client = createClient({ version: 'latest' });
Gradual Migration#
1// Feature flags for migration
2class ApiClient {
3 async getUsers() {
4 if (featureFlags.useV2Users) {
5 return this.v2.getUsers();
6 }
7 return this.v1.getUsers();
8 }
9}Best Practices#
1. Default to Latest Stable#
1// Explicit version required
2app.get('/users', (req, res) => {
3 const version = req.headers['api-version'];
4
5 if (!version) {
6 return res.status(400).json({
7 error: 'API version required. Use header: API-Version: v1',
8 });
9 }
10});
11
12// Or default to current
13app.get('/users', (req, res) => {
14 const version = req.headers['api-version'] || 'v2';
15 // ...
16});2. Version Response Includes#
1{
2 "data": [...],
3 "meta": {
4 "version": "v2",
5 "deprecation": null
6 }
7}3. Rate Limit by Version#
1// Encourage migration with rate limits
2const v1Limiter = rateLimit({ max: 100 }); // Lower limit
3const v2Limiter = rateLimit({ max: 1000 }); // Higher limit
4
5app.use('/v1', v1Limiter);
6app.use('/v2', v2Limiter);4. Monitor Version Usage#
1// Track version usage
2app.use((req, res, next) => {
3 const version = extractVersion(req);
4 metrics.increment('api_requests', { version });
5 next();
6});Choosing a Strategy#
URL versioning when:
- Public API with many clients
- Need clear documentation
- Caching is important
- Simple is better
Header versioning when:
- Internal APIs
- RESTful purity matters
- Clients are controlled
- URLs should represent resources
Query parameter when:
- Adding to existing API
- Optional versioning
- Simple implementation needed
Conclusion#
API versioning is about managing change while respecting your clients. Choose a strategy that fits your use case, document it clearly, and stick with it.
URL versioning works for most cases—it's simple, obvious, and works well with documentation and caching. Whatever you choose, the key is consistency and clear communication about deprecation timelines.
Your API is a contract. Versioning lets you evolve that contract without breaking the trust of developers who depend on it.