guide·intermediate·updated 2026-08-18

API Versioning That Doesn't Break Clients (URI + Backward Compatibility Strategy)

Version your API in the URL (/v1/, /v2/). Support multiple versions simultaneously for 12 months. Make changes additive (add fields, don't remove). When you deprecate, give 12-month notice. This prevents the nightmare of breaking 50 integrations.

API

TL;DR: Version your API in the URL (/v1/, /v2/). Support multiple versions simultaneously for 12 months. Make changes additive (add fields, don't remove). When you deprecate, give 12-month notice. This prevents the nightmare of breaking 50 integrations.


Why API Versioning Matters

You build an API. 50 developers integrate it. Everything works.

Then you need to change a response:

// Old (v1)
{ "user": { "name": "John", "email": "john@example.com" } }
 
// New (v2) - different structure
{ "data": { "profile": { "name": "John", "email": "john@example.com" } } }

All 50 integrations break. Your support inbox explodes. Developers hate you.

API versioning prevents this. Old integrations keep using v1. New integrations use v2. No breakage.


Versioning Strategies (And Why URI Wins)

Strategy 1: URI Versioning (RECOMMENDED)

GET /v1/users/123
GET /v2/users/123

Pros:

  • Clearest to clients (version is obvious in URL)
  • Easy to route (separate v1 and v2 handlers)
  • Backward compatible (old URL keeps working)
  • Standards-compliant (REST best practice) Cons: URLs look a bit verbose

Strategy 2: Header Versioning

GET /users/123
Header: Accept: application/vnd.company.v2+json

Pros: Cleaner URLs

Cons:

  • Clients forget the header
  • Harder to test (need to set headers)
  • Less discoverable
  • Harder to route in code

Strategy 3: Query Parameter

GET /users/123?version=2

Pros: Simple

Cons:

  • Easy to forget the parameter
  • Looks hacky
  • Not RESTful

Recommendation: Use URI versioning. It's the clearest for clients and easiest to implement.


Setup (Step by Step)

Step 1: Organize Your Routes by Version

const express = require('express');
const app = express();
 
// Separate routers for each version
const v1Routes = require('./routes/v1');
const v2Routes = require('./routes/v2');
 
// Mount routes with version prefix
app.use('/v1', v1Routes);
app.use('/v2', v2Routes);
 
// Redirect root to latest version (optional)
app.get('/api/users/:id', (req, res) => {
  res.redirect(`/v2/api/users/${req.params.id}`);
});

Step 2: v1 Route (Legacy)

// routes/v1/index.js
const router = require('express').Router();
const db = require('../../database');
 
// Old response format
router.get('/users/:id', async (req, res) => {
  const user = await db.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
  
  res.json({
    user: {
      id: user.id,
      name: user.name,
      email: user.email,
      createdAt: user.created_at
    }
  });
});
 
module.exports = router;

Step 3: v2 Route (New, Breaking Changes)

// routes/v2/index.js
const router = require('express').Router();
const db = require('../../database');
 
// New response format (added fields, changed structure)
router.get('/users/:id', async (req, res) => {
  const user = await db.query(
    'SELECT id, name, email, created_at, updated_at, status FROM users WHERE id = ?',
    [req.params.id]
  );
  
  res.json({
    data: {
      id: user.id,
      profile: {
        name: user.name,
        email: user.email
      },
      timestamps: {
        createdAt: user.created_at,
        updatedAt: user.updated_at
      },
      status: user.status // new field
    }
  });
});
 
module.exports = router;

Key: v1 and v2 coexist. Old clients hit /v1. New clients hit /v2. No breakage.


Backward Compatibility Patterns

Don't:

// 🚨 Breaking change - removed "email" field
{
  "data": { "id": 123, "name": "John" }
}

Do (Additive Only):

// ✅ Add new field, keep old ones
{
  "data": { "id": 123, "name": "John", "email": "john@example.com", "phone": "+1..." }
}

Why: Clients ignore unknown fields. They break if you remove fields they depend on.

Pattern 1: Add Fields (Safe)

// v2.1: Added phone field
{
  "data": {
    "id": 123,
    "name": "John",
    "email": "john@example.com",
    "phone": "+1-555-0123" // new
  }
}

Clients using v2.0 ignore phone. No breakage.

Pattern 2: Deprecate, Then Remove (Months Later)

// v2 (month 1): Include deprecated fields
{
  "data": {
    "id": 123,
    "name": "John",
    "email": "john@example.com",
    "deprecated_field": "value" // marked for removal
  }
}
 
// v3 (month 13): Removed deprecated_field
// Clients got 12 months notice to migrate
{
  "data": {
    "id": 123,
    "name": "John",
    "email": "john@example.com"
  }
}

Deprecation Timeline (12 Months Standard)

Month 1: Release v2

  • Documentation: "v1 will be sunset December 31, 2026"
  • Add header to v1 responses: Deprecation: true
  • Add sunset header: Sunset: Sun, 31 Dec 2026 23:59:59 GMT
router.get('/v1/users/:id', (req, res) => {
  const user = /* ... */;
  
  res.set('Deprecation', 'true');
  res.set('Sunset', 'Sun, 31 Dec 2026 23:59:59 GMT');
  res.set('Link', '</v2/users/123>; rel="successor-version"');
  
  res.json(user);
});

Month 6: Send email to all v1 users

  • "v1 sunset in 6 months"
  • Link to migration guide
  • Offer assistance Month 11: Final warning
  • "v1 sunset in 30 days"
  • Support email for questions Month 12: v1 shut down
  • All v1 requests → 410 Gone (or redirect to v2)

Real Scenario: 50 Integrations, Multiple Versions

You have 50 integrations using your API.

Month 1: Release v2 (major changes)

  • 30 integrations stay on v1 (not ready to migrate)
  • 20 integrations adopt v2 immediately
  • Both versions live side-by-side Month 3: 40 integrations on v2, 10 on v1

Month 6: Email: "v1 sunset in 6 months"

  • Last 10 integrations start migration Month 11: 49 on v2, 1 laggard still on v1
  • Direct outreach to that company Month 12: Sunset v1
  • That 1 company had to migrate (or their integration broke, but they had notice) Result: No surprise breakage. Everyone had time. No angry support emails.

Monitoring API Usage by Version

Track which clients use which version:

app.use((req, res, next) => {
  const version = req.path.match(/^\/v(\d+)/)?.[1];
  const endpoint = req.path;
  
  console.log({
    timestamp: new Date(),
    version,
    endpoint,
    method: req.method,
    clientIp: req.ip,
    userAgent: req.get('user-agent')
  });
  
  next();
});

Dashboard queries:

  • How many requests hit v1 vs v2?
  • Are any v1 clients still active?
  • Which endpoints are most used? Alerts:
  • If v1 traffic spikes (something broke?)
  • If new clients start using deprecated version

Documentation for Each Version

Create separate docs:

/docs/v1/users.md
/docs/v2/users.md
/docs/v3/users.md

Each shows:

  • Endpoint
  • Request format
  • Response format (THIS version)
  • Deprecation notice (if any)
  • Migration guide to next version

Testing Across Versions

Use contract tests to prevent surprises:

// test/contracts.js
describe('API Contracts', () => {
  it('v1 users endpoint returns expected fields', async () => {
    const response = await request(app).get('/v1/users/123');
    
    expect(response.body).toHaveProperty('user.id');
    expect(response.body).toHaveProperty('user.name');
    expect(response.body).toHaveProperty('user.email');
  });
 
  it('v2 users endpoint returns expected fields', async () => {
    const response = await request(app).get('/v2/users/123');
    
    expect(response.body).toHaveProperty('data.id');
    expect(response.body).toHaveProperty('data.profile.name');
    expect(response.body).toHaveProperty('data.timestamps.createdAt');
  });
 
  it('v2 response includes deprecated_field for compatibility', async () => {
    const response = await request(app).get('/v2/users/123');
    
    // Verify new clients get the field
    expect(response.body.data).toHaveProperty('deprecated_field');
  });
});

Why: When you write v3, contract tests ensure v2 didn't accidentally break someone.


Common Mistakes

Mistake Impact Fix
No versioning One breaking change = break all clients Version from day 1 (/v1/)
Break v1 suddenly Angry integrations, lost trust 12-month deprecation notice
Support too many versions Code becomes unmaintainable (3+ versions = complexity) Sunset old versions after 12 months
No deprecation headers Clients don't know it's ending Add Deprecation + Sunset headers
Don't document each version Clients confused about differences Separate docs for each version
Ignore usage analytics Don't know who's still using old version Monitor and alert

Production Checklist

  • ✅ Version in URL (/v1/, /v2/)
  • ✅ Both versions documented separately
  • ✅ Backward compatibility: only additive changes
  • ✅ Deprecation headers on old versions (Deprecation, Sunset, Link)
  • ✅ Deprecation notice: 12 months minimum
  • ✅ Monitoring: track usage by version
  • ✅ Contract tests: verify no accidental breakage
  • ✅ Migration guide: from old to new version
  • ✅ Support: respond to migration questions

Your Competitive Edge

Founders using proper API versioning:

  • Clients trust you (won't suddenly break integrations)
  • Can evolve API confidently
  • No angry support emails about breaking changes
  • Can deprecate old versions cleanly
  • Have data on which clients use what (strategic insights)

Start this week. If you don't have versioning yet, add /v1/ to your current API. When you need changes, create /v2/. 12-month deprecation. Everyone stays happy.

Got stuck, or want this shipped end-to-end for you? bitroot.club builds custom products for founders. →