API Versioning Best Practices

If you've ever worked with APIs, you know that change is inevitable. New features get added, bugs get fixed, and sometimes you need to make breaking changes that could disrupt existing clients. That's where API versioning comes in. Versioning your API isn't just a nice-to-have—it's essential for maintaining backward

TRY NANO BANANA FOR FREE

API Versioning Best Practices

TRY NANO BANANA FOR FREE
Contents

If you've ever worked with APIs, you know that change is inevitable. New features get added, bugs get fixed, and sometimes you need to make breaking changes that could disrupt existing clients. That's where API versioning comes in.

Versioning your API isn't just a nice-to-have—it's essential for maintaining backward compatibility while still allowing your API to evolve. But with multiple versioning strategies available, how do you choose the right one? And once you've picked a strategy, how do you actually implement it without breaking things for your users?

In this guide, we'll walk through everything you need to know about API versioning, from the different approaches you can take to practical implementation details and deprecation strategies.

Why API Versioning Matters

Before we dive into the how, let's talk about the why. API versioning solves a fundamental problem: how do you improve your API without breaking existing integrations?

Imagine you have thousands of clients using your API. You want to change how authentication works, or maybe you need to restructure your response format for better performance. Without versioning, you'd have two bad options: force everyone to update at once (causing chaos), or never make breaking changes (stagnating your API).

Versioning gives you a third option: introduce changes in a new version while keeping the old version running. Clients can migrate on their own timeline, and you can eventually deprecate old versions once adoption is high enough.

The Three Main Versioning Strategies

There are three popular ways to version REST APIs, each with its own trade-offs. Let's look at each one.

1. URL Versioning

This is the most common approach. You include the version number directly in the URL path:

GET https://api.example.com/v1/pets
GET https://api.example.com/v2/pets

Here's what a simple implementation might look like in Express.js:

const express = require('express');
const app = express();

// V1 routes
app.get('/v1/pets', (req, res) => {
  res.json({
    pets: [
      { id: 1, name: 'Fluffy', type: 'cat' }
    ]
  });
});

// V2 routes with enhanced response
app.get('/v2/pets', (req, res) => {
  res.json({
    data: [
      {
        id: 1,
        name: 'Fluffy',
        species: 'cat',
        breed: 'Persian',
        age: 3
      }
    ],
    meta: {
      total: 1,
      version: '2.0'
    }
  });
});

Pros:- Extremely visible and easy to understand - Simple to route and cache - Works great with API gateways - Easy to test different versions side-by-side

Cons:- Violates REST principles (the resource shouldn't change based on version) - Can lead to URL proliferation - Makes it harder to share URLs between versions

2. Header Versioning

With header versioning, the URL stays the same, but you specify the version in a custom header:

GET https://api.example.com/pets
Accept: application/vnd.example.v1+json

Or using a custom header:

GET https://api.example.com/pets
API-Version: 1

Here's how you might implement this:

app.get('/pets', (req, res) => {
  const version = req.headers['api-version'] || '1';

  if (version === '1') {
    return res.json({
      pets: [{ id: 1, name: 'Fluffy', type: 'cat' }]
    });
  }

  if (version === '2') {
    return res.json({
      data: [{
        id: 1,
        name: 'Fluffy',
        species: 'cat',
        breed: 'Persian'
      }],
      meta: { total: 1 }
    });
  }

  res.status(400).json({ error: 'Unsupported API version' });
});

Pros:- Keeps URLs clean and RESTful - Follows HTTP standards (using Accept header) - Same resource, different representations

Cons:- Less visible—harder to discover versions - More complex to test and debug - Caching can be tricky - Not all HTTP clients make it easy to set custom headers

3. Query Parameter Versioning

This approach puts the version in a query parameter:

GET https://api.example.com/pets?version=1
GET https://api.example.com/pets?version=2

Implementation is straightforward:

app.get('/pets', (req, res) => {
  const version = req.query.version || '1';

  switch(version) {
    case '1':
      return res.json({
        pets: [{ id: 1, name: 'Fluffy', type: 'cat' }]
      });
    case '2':
      return res.json({
        data: [{
          id: 1,
          name: 'Fluffy',
          species: 'cat'
        }]
      });
    default:
      return res.status(400).json({
        error: 'Invalid version'
      });
  }
});

Pros:- Easy to implement and test - Visible in URLs - Optional (can default to latest or oldest)

Cons:- Mixes versioning with query parameters meant for filtering - Can be accidentally omitted - Not as clean as URL versioning

Which Strategy Should You Choose?

There's no universally "correct" answer, but here's my take based on years of building APIs:

Use URL versioning if:- You're building a public API - Simplicity and discoverability are priorities - You want easy caching and routing - You're okay with the URL changing

Use header versioning if:- You want to follow REST principles strictly - You have sophisticated clients that can handle headers - You want the same URL to serve different versions - You're building an internal API with controlled clients

Use query parameter versioning if:- You want something simple and visible - You need optional versioning (defaulting to a version) - Your clients are simple (like browsers or basic HTTP tools)

For most projects, I recommend URL versioning. It's the most pragmatic choice—easy to understand, easy to implement, and easy to debug.

Semantic Versioning for APIs

Once you've chosen a versioning strategy, you need to decide how to number your versions. Semantic versioning (semver) is a popular approach that uses three numbers: MAJOR.MINOR.PATCH.

v2.1.3
│ │ │
│ │ └─ PATCH: Bug fixes, no breaking changes
│ └─── MINOR: New features, backward compatible
└───── MAJOR: Breaking changes

For APIs, you typically only expose the MAJOR version to clients:

GET /v2/pets  (internally might be 2.1.3)

Here's how to think about when to bump each number:

MAJOR version (breaking changes): - Removing endpoints or fields - Changing response structure - Changing authentication methods - Renaming fields - Changing data types

MINOR version (backward compatible): - Adding new endpoints - Adding optional parameters - Adding new fields to responses - Adding new optional headers

PATCH version (bug fixes): - Fixing bugs - Performance improvements - Documentation updates - Internal refactoring

Example of tracking this in your code:

const API_VERSION = {
  major: 2,
  minor: 1,
  patch: 3,
  full: '2.1.3'
};

app.get('/version', (req, res) => {
  res.json({
    version: API_VERSION.full,
    major: API_VERSION.major
  });
});

When to Create a New Version

Not every change requires a new version. Here's a practical decision tree:

Create a new MAJOR version when:- You're removing functionality - You're changing existing behavior in incompatible ways - You're restructuring responses significantly - Migration requires code changes from clients

Don't create a new version when:- You're adding optional fields - You're adding new endpoints - You're fixing bugs - You're improving performance without changing behavior

A good rule of thumb: if existing client code would break without changes, you need a new major version.

Implementing Version Routing

Let's look at a more complete example of version routing with middleware:

const express = require('express');
const app = express();

// Version detection middleware
function detectVersion(req, res, next) {
  // Extract from URL path
  const pathMatch = req.path.match(/^\/v(\d+)\//);
  if (pathMatch) {
    req.apiVersion = parseInt(pathMatch[1]);
    req.path = req.path.replace(/^\/v\d+/, '');
  } else {
    req.apiVersion = 1; // default version
  }
  next();
}

app.use(detectVersion);

// Version-specific controllers
const petsV1 = require('./controllers/v1/pets');
const petsV2 = require('./controllers/v2/pets');

app.get('/pets', (req, res) => {
  if (req.apiVersion === 1) {
    return petsV1.list(req, res);
  }
  if (req.apiVersion === 2) {
    return petsV2.list(req, res);
  }
  res.status(400).json({
    error: 'Unsupported API version',
    supported: [1, 2]
  });
});

For larger applications, consider organizing by version:

/controllers
  /v1
    pets.js
    users.js
  /v2
    pets.js
    users.js

Deprecating Old Versions

Eventually, you'll want to retire old API versions. Here's a gradual deprecation process that respects your users:

Step 1: Announce Deprecation (6-12 months before)

Add deprecation headers to responses:

app.use('/v1/*', (req, res, next) => {
  res.set({
    'Deprecation': 'true',
    'Sunset': 'Sat, 31 Dec 2024 23:59:59 GMT',
    'Link': '<https://api.example.com/v2>; rel="successor-version"'
  });
  next();
});

Step 2: Monitor Usage

Track which clients are still using old versions:

const versionMetrics = {};

app.use((req, res, next) => {
  const version = req.apiVersion;
  versionMetrics[version] = (versionMetrics[version] || 0) + 1;
  next();
});

// Endpoint to check metrics
app.get('/admin/metrics', (req, res) => {
  res.json(versionMetrics);
});

Step 3: Reach Out to Heavy Users

Identify clients still on old versions and contact them:

const clientVersions = new Map();

app.use((req, res, next) => {
  const apiKey = req.headers['x-api-key'];
  const version = req.apiVersion;

  if (apiKey) {
    clientVersions.set(apiKey, {
      version,
      lastSeen: new Date(),
      requestCount: (clientVersions.get(apiKey)?.requestCount || 0) + 1
    });
  }
  next();
});

Step 4: Return Warnings

As the sunset date approaches, add warning messages:

app.use('/v1/*', (req, res, next) => {
  const sunsetDate = new Date('2024-12-31');
  const daysUntilSunset = Math.floor(
    (sunsetDate - new Date()) / (1000 * 60 * 60 * 24)
  );

  if (daysUntilSunset < 90) {
    res.set('Warning',
      `299 - "API v1 will be deprecated in ${daysUntilSunset} days"`
    );
  }
  next();
});

Step 5: Disable the Version

Finally, return 410 Gone for deprecated versions:

app.use('/v1/*', (req, res) => {
  res.status(410).json({
    error: 'API version 1 has been deprecated',
    message: 'Please upgrade to v2',
    migrationGuide: 'https://docs.example.com/migration/v1-to-v2',
    supportedVersions: [2, 3]
  });
});

Best Practices Summary

Here are the key takeaways for API versioning:

Choose URL versioning for simplicity - It's the most straightforward for both you and your users

Only expose major versions - Keep it simple; clients don't need to know about minor versions

Make breaking changes sparingly - Try to extend rather than change whenever possible

Give plenty of notice - 6-12 months minimum for deprecations

Provide migration guides - Make it easy for clients to upgrade

Monitor version usage - Know who's using what before you deprecate

Use semantic versioning internally - Even if you only expose major versions

Default to the oldest stable version - Don't force upgrades unexpectedly

Document version differences clearly - Make it obvious what changed between versions

Test all supported versions - Don't let old versions rot

Wrapping Up

API versioning doesn't have to be complicated. Pick a strategy that fits your needs (URL versioning is usually the best bet), use semantic versioning to track changes, and be thoughtful about deprecation.

The goal isn't to have perfect versioning from day one—it's to have a system that lets you evolve your API without breaking things for your users. Start simple, version when you need to make breaking changes, and always give your users plenty of time to migrate.

Your future self (and your users) will thank you for thinking about versioning early rather than trying to bolt it on later.