guide·intermediate·updated 2026-08-18·20–30 minutes to set up versioned routing

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, not destructive. When you deprecate, give 12-month notice. This prevents the nightmare of breaking 50 integrations.

APIVersioningBackendNode.js
you'll need
  • ●An API with at least one existing consumer (internal or external)
  • ●A Node.js server (Express or similar) — the routing pattern applies to any framework
  • ●A plan for where deprecation notices and migration guides will live
you'll end up with

URI-versioned routes (/v1/, /v2/) that can coexist indefinitely, an additive-changes discipline that keeps old clients working, a 12-month deprecation timeline with Deprecation/Sunset headers, usage monitoring by version, and contract tests that catch accidental breakage before it ships.

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

§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 the URL), easy to route (separate v1 and v2 handlers), backward compatible (old URL keeps working), and 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, it's harder to test (need to set headers), it's less discoverable, and it's harder to route in code.

Strategy 3: query parameter

GET /users/123?version=2

Pros: simple. Cons: easy to forget the parameter, looks hacky, and isn't RESTful.

§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
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
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;

§Backward compatibility patterns

Don't do this — a breaking change that removes the "email" field:

{
  "data": { "id": 123, "name": "John" }
}

Do this instead — additive only, add a new field, keep the 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 says "v1 will be sunset December 31, 2026." Add a header to v1 responses: Deprecation: true. Add a 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 the 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 return 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 adopt v2 immediately. Both versions live side-by-side.
  • ●Month 3: 40 integrations on v2, 10 on v1.
  • ●Month 6: email sent — "v1 sunset in 6 months." The 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).

§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?), or if new clients start using a deprecated version.

§Documentation for each version

Create separate docs:

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

Each shows the endpoint, request format, response format (for that version), a deprecation notice (if any), and a migration guide to the next version.

§Testing across versions

Use contract tests to prevent surprises:

test/contracts.js
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

  • ●No versioning — one breaking change breaks all clients. Fix: version from day 1 (/v1/).
  • ●Break v1 suddenly — angry integrations, lost trust. Fix: 12-month deprecation notice.
  • ●Support too many versions — code becomes unmaintainable (3+ versions = complexity). Fix: sunset old versions after 12 months.
  • ●No deprecation headers — clients don't know it's ending. Fix: add Deprecation + Sunset headers.
  • ●Don't document each version — clients are confused about the differences. Fix: separate docs for each version.
  • ●Ignore usage analytics — you don't know who's still using the old version. Fix: 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 keep client trust (won't suddenly break integrations), can evolve their API confidently, avoid angry support emails about breaking changes, can deprecate old versions cleanly, and have data on which clients use what — a real strategic insight.

Shipped it but want a second pair of eyes on your copy, DNS, or email deliverability? bitroot.club does a $0 launch review for anyone who followed this guide. →