Back to Blog
APIVersioningRESTBackend

API Versioning: Strategies for Evolving APIs

Choose the right API versioning strategy. Learn URL versioning, header versioning, and patterns for maintaining backward compatibility.

B
Bootspring Team
Engineering
February 26, 2026
7 min read

APIs evolve over time, and versioning strategies determine how you manage changes without breaking existing clients. This guide covers versioning approaches and best practices.

Why Version APIs?#

  • Breaking changes: New requirements may require incompatible changes
  • Multiple clients: Different clients may need different versions
  • Gradual migration: Allow time for clients to update
  • Clear contracts: Explicit version means clear expectations

Versioning Strategies#

URL Path Versioning#

Most common and explicit approach:

1// Express routes with URL versioning 2import { Router } from 'express'; 3 4const v1Router = Router(); 5const v2Router = Router(); 6 7// V1 API 8v1Router.get('/users/:id', async (req, res) => { 9 const user = await db.users.findById(req.params.id); 10 res.json({ 11 id: user.id, 12 name: user.name, 13 email: user.email, 14 }); 15}); 16 17// V2 API - different response structure 18v2Router.get('/users/:id', async (req, res) => { 19 const user = await db.users.findById(req.params.id); 20 res.json({ 21 data: { 22 id: user.id, 23 attributes: { 24 name: user.name, 25 email: user.email, 26 createdAt: user.createdAt, 27 }, 28 links: { 29 self: `/api/v2/users/${user.id}`, 30 }, 31 }, 32 }); 33}); 34 35app.use('/api/v1', v1Router); 36app.use('/api/v2', v2Router);

Pros:

  • Clear and explicit
  • Easy to understand and implement
  • Works with caching

Cons:

  • Multiple code paths to maintain
  • URL pollution

Header Versioning#

Version specified in request headers:

1// Version middleware using Accept header 2function versionMiddleware(req: Request, res: Response, next: NextFunction) { 3 const acceptHeader = req.headers.accept || ''; 4 5 // Parse: application/vnd.myapi.v2+json 6 const match = acceptHeader.match(/application\/vnd\.myapi\.v(\d+)\+json/); 7 8 if (match) { 9 req.apiVersion = parseInt(match[1]); 10 } else { 11 req.apiVersion = 1; // Default version 12 } 13 14 next(); 15} 16 17// Or custom header 18function customHeaderVersion(req: Request, res: Response, next: NextFunction) { 19 const version = req.headers['x-api-version']; 20 req.apiVersion = version ? parseInt(version as string) : 1; 21 next(); 22} 23 24// Route handler using version 25app.get('/api/users/:id', versionMiddleware, async (req, res) => { 26 const user = await db.users.findById(req.params.id); 27 28 if (req.apiVersion === 2) { 29 return res.json({ 30 data: { id: user.id, attributes: { name: user.name } }, 31 }); 32 } 33 34 // V1 response 35 res.json({ id: user.id, name: user.name }); 36});

Pros:

  • Clean URLs
  • Flexible

Cons:

  • Less discoverable
  • Harder to test in browser

Query Parameter Versioning#

1// Version via query parameter 2app.get('/api/users/:id', async (req, res) => { 3 const version = parseInt(req.query.version as string) || 1; 4 const user = await db.users.findById(req.params.id); 5 6 const transformers = { 7 1: (u: User) => ({ id: u.id, name: u.name }), 8 2: (u: User) => ({ 9 data: { id: u.id, attributes: { name: u.name, email: u.email } }, 10 }), 11 }; 12 13 const transformer = transformers[version] || transformers[1]; 14 res.json(transformer(user)); 15});

Pros:

  • Easy to implement
  • Easy to test

Cons:

  • Can be cached incorrectly
  • Pollutes query string

Implementing Version Transformers#

Response Transformation Layer#

1interface VersionTransformer<T, R> { 2 version: number; 3 transform: (data: T) => R; 4} 5 6class APIVersionManager<T> { 7 private transformers: Map<number, (data: T) => any> = new Map(); 8 9 register(version: number, transformer: (data: T) => any) { 10 this.transformers.set(version, transformer); 11 } 12 13 transform(data: T, version: number): any { 14 const transformer = this.transformers.get(version); 15 if (!transformer) { 16 throw new Error(`Unsupported API version: ${version}`); 17 } 18 return transformer(data); 19 } 20} 21 22// Usage 23const userVersionManager = new APIVersionManager<User>(); 24 25userVersionManager.register(1, (user) => ({ 26 id: user.id, 27 name: user.name, 28})); 29 30userVersionManager.register(2, (user) => ({ 31 data: { 32 type: 'user', 33 id: user.id, 34 attributes: { 35 name: user.name, 36 email: user.email, 37 createdAt: user.createdAt.toISOString(), 38 }, 39 }, 40})); 41 42app.get('/api/users/:id', async (req, res) => { 43 const user = await db.users.findById(req.params.id); 44 const version = req.apiVersion || 1; 45 46 res.json(userVersionManager.transform(user, version)); 47});

Request/Response Adapters#

1interface APIAdapter { 2 version: number; 3 parseRequest: (body: any) => any; 4 formatResponse: (data: any) => any; 5} 6 7const v1Adapter: APIAdapter = { 8 version: 1, 9 parseRequest: (body) => ({ 10 name: body.name, 11 email: body.email, 12 }), 13 formatResponse: (user) => ({ 14 id: user.id, 15 name: user.name, 16 }), 17}; 18 19const v2Adapter: APIAdapter = { 20 version: 2, 21 parseRequest: (body) => ({ 22 name: body.data?.attributes?.name, 23 email: body.data?.attributes?.email, 24 metadata: body.data?.meta, 25 }), 26 formatResponse: (user) => ({ 27 data: { 28 type: 'user', 29 id: user.id, 30 attributes: { 31 name: user.name, 32 email: user.email, 33 }, 34 }, 35 links: { 36 self: `/api/v2/users/${user.id}`, 37 }, 38 }), 39}; 40 41function getAdapter(version: number): APIAdapter { 42 const adapters = { 1: v1Adapter, 2: v2Adapter }; 43 return adapters[version] || v1Adapter; 44} 45 46app.post('/api/users', async (req, res) => { 47 const adapter = getAdapter(req.apiVersion); 48 const userData = adapter.parseRequest(req.body); 49 50 const user = await db.users.create(userData); 51 52 res.json(adapter.formatResponse(user)); 53});

Deprecation Strategy#

Deprecation Headers#

1function deprecationMiddleware( 2 deprecatedIn: string, 3 sunsetDate: string 4) { 5 return (req: Request, res: Response, next: NextFunction) => { 6 res.setHeader('Deprecation', `date="${deprecatedIn}"`); 7 res.setHeader('Sunset', sunsetDate); 8 res.setHeader( 9 'Link', 10 '</api/v2/users>; rel="successor-version"' 11 ); 12 next(); 13 }; 14} 15 16// Apply to deprecated endpoints 17v1Router.use(deprecationMiddleware('2024-01-01', 'Sat, 01 Jul 2024 00:00:00 GMT'));

Version Lifecycle#

1enum VersionStatus { 2 CURRENT = 'current', 3 DEPRECATED = 'deprecated', 4 SUNSET = 'sunset', 5} 6 7interface APIVersion { 8 version: number; 9 status: VersionStatus; 10 deprecatedAt?: Date; 11 sunsetAt?: Date; 12 successorVersion?: number; 13} 14 15const versions: APIVersion[] = [ 16 { 17 version: 1, 18 status: VersionStatus.DEPRECATED, 19 deprecatedAt: new Date('2024-01-01'), 20 sunsetAt: new Date('2024-07-01'), 21 successorVersion: 2, 22 }, 23 { 24 version: 2, 25 status: VersionStatus.CURRENT, 26 }, 27]; 28 29function versionStatusMiddleware(req: Request, res: Response, next: NextFunction) { 30 const version = versions.find(v => v.version === req.apiVersion); 31 32 if (!version) { 33 return res.status(400).json({ error: 'Unknown API version' }); 34 } 35 36 if (version.status === VersionStatus.SUNSET) { 37 return res.status(410).json({ 38 error: 'API version no longer available', 39 successorVersion: version.successorVersion, 40 }); 41 } 42 43 if (version.status === VersionStatus.DEPRECATED) { 44 res.setHeader('X-API-Deprecated', 'true'); 45 res.setHeader('X-API-Sunset-Date', version.sunsetAt!.toISOString()); 46 } 47 48 next(); 49}

Backward Compatibility#

Additive Changes (Safe)#

// V1 response { "id": 1, "name": "John" } // V1.1 response - added field (backward compatible) { "id": 1, "name": "John", "email": "john@example.com" }

Breaking Changes (Require New Version)#

1// V1 response 2{ "id": 1, "name": "John" } 3 4// V2 response - restructured (breaking change) 5{ 6 "data": { 7 "id": 1, 8 "attributes": { "name": "John" } 9 } 10} 11 12// V1 field removed - breaking 13// V1: { "fullName": "John Doe" } 14// V2: { "firstName": "John", "lastName": "Doe" }

Compatibility Layer#

1// Maintain backward compatibility through transformation 2class CompatibilityLayer { 3 // Old field name -> new field name 4 private fieldMappings: Record<string, string> = { 5 fullName: 'name', 6 userName: 'username', 7 }; 8 9 transformRequest(body: any, fromVersion: number): any { 10 if (fromVersion === 1) { 11 // Transform V1 request to current format 12 return this.migrateV1ToV2(body); 13 } 14 return body; 15 } 16 17 private migrateV1ToV2(body: any): any { 18 const transformed = { ...body }; 19 20 for (const [oldKey, newKey] of Object.entries(this.fieldMappings)) { 21 if (oldKey in transformed) { 22 transformed[newKey] = transformed[oldKey]; 23 delete transformed[oldKey]; 24 } 25 } 26 27 return transformed; 28 } 29}

Documentation#

OpenAPI with Multiple Versions#

1# openapi-v1.yaml 2openapi: 3.0.0 3info: 4 title: My API 5 version: '1.0' 6 x-deprecated: true 7 x-sunset-date: '2024-07-01' 8 9# openapi-v2.yaml 10openapi: 3.0.0 11info: 12 title: My API 13 version: '2.0' 14 15paths: 16 /users/{id}: 17 get: 18 summary: Get user by ID 19 parameters: 20 - name: id 21 in: path 22 required: true 23 schema: 24 type: string 25 responses: 26 '200': 27 description: User found 28 content: 29 application/json: 30 schema: 31 $ref: '#/components/schemas/UserResponse'

Best Practices#

  1. Version from the start: Plan for versioning even in v1
  2. Use semantic versioning: Major for breaking, minor for features
  3. Document changes: Maintain a changelog per version
  4. Set deprecation timeline: Give clients time to migrate
  5. Monitor version usage: Track which versions are in use
  6. Test all versions: Automated tests for each supported version
  7. Communicate clearly: Notify clients of deprecation plans

Conclusion#

Choose a versioning strategy that fits your use case and stick with it. URL versioning is the most explicit and widely used. Focus on maintaining backward compatibility where possible, and have a clear deprecation policy for managing version lifecycle.

Share this article

Help spread the word about Bootspring