Back to Blog
API DesignVersioningBest PracticesArchitecture

API Versioning Strategies: A Practical Guide

Choose the right API versioning strategy for your project. From URL versioning to headers to content negotiation.

B
Bootspring Team
Engineering
May 28, 2025
6 min read

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.ts

Pros:

  • 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#

  1. Update response parsing to handle nested profile
  2. Rename avatar_url to profile.avatar
  3. Note: name moved to profile.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.

Share this article

Help spread the word about Bootspring