web_dev

API Versioning Done Right: Strategies, Code Patterns, and Sunsetting Old Versions

Learn how to version your REST API using URI paths, headers, and content negotiation. Avoid breaking changes and keep clients happy with proven strategies.

API Versioning Done Right: Strategies, Code Patterns, and Sunsetting Old Versions

When I started building APIs, I made a mistake that cost me weeks of work. I launched a simple web service, a few endpoints for users and products. A few months later, I added a field to the response. One of my clients, a small mobile app, crashed immediately. Their code expected a specific JSON structure. When I added the email field, their parser broke. That was the day I learned about versioning.

You see, APIs are like promises. You tell a client, “Send this request, and I will give you exactly this response.” If you change the response, you break the promise. Versioning is how you give yourself room to improve while keeping your old promises alive.

Let me walk you through four ways to version your API. They all solve the same problem, but each has a different feel. I will show you code, tell you why I prefer one over another, and explain how to handle the messy business of sunsetting old versions.


URI Path Versioning – The Obvious Choice

The simplest approach is to put the version number right in the URL. /v1/users, /v2/users. It is clear, it is explicit, and it is easy to route. When I first used this, I felt clever. I could see the version right in the logs. I could tell clients exactly which version to call.

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

// Version 1
app.get('/v1/users/:id', (req, res) => {
  res.json({ id: req.params.id, name: 'Alice' });
});

// Version 2
app.get('/v2/users/:id', (req, res) => {
  res.json({ id: req.params.id, name: 'Alice', email: '[email protected]' });
});

This works. But soon you have dozens of routes, each with a version prefix. Your codebase bloats with duplicated controllers. One version’s bug fix must be applied to every copy. Maintenance becomes a nightmare.

I remember staring at two nearly identical files, userControllerV1.js and userControllerV2.js, thinking there must be a better way. There is. But before we get there, let’s see the other options.


Query Parameter Versioning – The Lazy Way

Some people put the version in the query string. /users?id=123&version=2. It is the laziest method. No need to change URLs. Just read a parameter.

app.get('/users/:id', (req, res) => {
  const version = req.query.version || '1';
  // ... logic
});

I tried this once. It works fine until someone caches the response. A proxy might cache /users/123?version=1 and serve it for version=2. Also, you cannot tell from the URL alone what version is being used. It feels fragile.


Custom Request Header – The Clean URL Method

A cleaner approach is to put the version in a custom header. For example, Accept-Version: v2. The URL stays pure. The resource identity remains the same. The client tells you which version they want.

app.get('/users/:id', (req, res) => {
  const version = req.headers['accept-version'] || '1';
  // ... handle versions
});

This keeps your routes clean. But now you must document the header. Some developers forget to send it. Your logs might not show the version by default. You need to add middleware to extract and log it.

I used this in one project. It felt elegant, but my clients kept asking, “Where do I put the version number?” The answer was always, “In a header.” They looked at me like I was speaking a foreign language.


Content Negotiation (Accept Header) – The HTTP Purist Way

The most “RESTful” way is to use the Accept header with custom media types. You define something like application/vnd.myapp.v2+json. The client asks for a specific representation.

app.get('/users/:id', (req, res) => {
  const accept = req.get('Accept');
  if (accept && accept.includes('vnd.myapp.v2+json')) {
    return res.json({ id: req.params.id, name: 'Alice', email: '[email protected]' });
  }
  res.json({ id: req.params.id, name: 'Alice' });
});

This is beautiful on paper. The version negotiation happens at the protocol level. Caches understand it. URLs remain pure. But the developer experience suffers. Clients have to set the right Accept header. I have seen teams spend days debugging why their API returned the wrong version. It is not obvious.


My Favorite Approach – A Pragmatic Mix

After years of trial and error, I settled on a mix. I use URI path versioning for major version changes (v1, v2, v3) and use header-based versioning for minor changes within a major version. This way the URL tells the story, but I can add fields without forcing a new path.

// Major version in path
app.get('/v1/users/:id', (req, res) => {
  // But we still allow minor version via header
  const minor = req.headers['x-api-minor-version'] || '0';
  const user = { id: req.params.id, name: 'Alice' };
  if (minor === '1') {
    user.email = '[email protected]';
  }
  res.json(user);
});

But more importantly, I organize my code differently. Instead of duplicating controllers, I use a versioned handler pattern.

class UserV1Handler {
  getUser(id) {
    return { id, name: 'Alice' };
  }
}

class UserV2Handler extends UserV1Handler {
  getUser(id) {
    const base = super.getUser(id);
    base.email = '[email protected]';
    return base;
  }
}

function getHandler(version) {
  if (version === 'v2') return new UserV2Handler();
  return new UserV1Handler();
}

app.get('/v1/users/:id', (req, res) => {
  const handler = getHandler('v1');
  res.json(handler.getUser(req.params.id));
});
app.get('/v2/users/:id', (req, res) => {
  const handler = getHandler('v2');
  res.json(handler.getUser(req.params.id));
});

This keeps the logic in one place. Adding a new version only requires a new handler that overrides specific parts.


Deprecation and Sunsetting – The Hard Part

You cannot keep old versions forever. They become dead weight. Every bug in v1 must be fixed even if only three clients use it. You need a plan to kill them.

I learned this the hard way. I had a v1 endpoint that returned a string date format. v2 returned ISO dates. I announced deprecation, but no one moved. I had to cut them off.

Here is the framework I use now.

First, decide a policy. I support each major version for at least two years. I announce deprecation six months before sunset. I put warnings in response headers.

// Middleware to deprecate versions
const VERSIONS = {
  v1: { deprecated: new Date('2024-01-01'), sunset: new Date('2024-12-31') },
  v2: { deprecated: null, sunset: null }
};

app.use('/api', (req, res, next) => {
  const version = req.path.split('/')[1]; // 'v1' from /v1/users/...
  const versionInfo = VERSIONS[version];
  if (versionInfo && versionInfo.deprecated && new Date() >= versionInfo.deprecated) {
    res.set('Deprecation', 'true');
    res.set('Sunset', versionInfo.sunset.toISOString());
  }
  next();
});

Then, on the sunset date, I return a 410 Gone status with a helpful message.

app.use('/api', (req, res, next) => {
  const version = req.path.split('/')[1];
  const versionInfo = VERSIONS[version];
  if (versionInfo && versionInfo.sunset && new Date() >= versionInfo.sunset) {
    return res.status(410).json({
      error: 'This API version is no longer supported.',
      migrateTo: '/v2/...'
    });
  }
  next();
});

I also log all calls to deprecated versions. I email the registered contacts of clients that still use them. I give them a deadline. It works. But it requires discipline.


Testing Each Version

When you have multiple versions, you need to test each one. I automate this. I write tests that iterate over active versions.

const versions = ['v1', 'v2'];
versions.forEach((version) => {
  describe(`GET /${version}/users/:id`, () => {
    it('returns user data', async () => {
      const res = await request(app).get(`/${version}/users/123`);
      expect(res.status).toBe(200);
      if (version === 'v2') {
        expect(res.body.email).toBeDefined();
      } else {
        expect(res.body.email).toBeUndefined();
      }
    });
  });
});

This catches regressions. If I change v2 logic and accidentally break v1, the tests fail.


Client Migration – Make It Easy

The hardest part is getting clients to move. I try to make migration as painless as possible.

I provide a compatibility check endpoint. A client can send a request with their current data and get back a report: “Your code will work with v2 if you handle the new email field.”

app.post('/migration-check', (req, res) => {
  const body = req.body;
  const issues = [];
  // simulate v2 response
  if (!body.expectedFields.includes('email')) {
    issues.push('v2 adds email field. Your parser must ignore unknown fields or use it.');
  }
  res.json({ compatible: issues.length === 0, issues });
});

I also offer a sandbox environment where they can test v2 without affecting production. And I give clear migration guides with before/after code examples.


Personal Touches from My Mistakes

I once built an API where I thought versioning was unnecessary. The app was small. We were the only client. Then we grew. We added another client. Then another. When we changed the API, everyone broke. I spent weekends fixing other people’s code. That’s when versioning became my religion.

Another time, I used version numbers in the URL but forgot to version all endpoints. The /products endpoint had no version. Clients guessed it was the latest. I created a mess. Now, every endpoint has a version even if it’s the same as the previous one. Consistency matters.


When Not to Version

Sometimes you don’t need versioning. If your API is internal and you control all clients, you can coordinate changes. If your API is still in preview (alpha/beta), you can break things. But once you have paying customers, versioning is a requirement.


Caching and Versions

Caching can be tricky with versioning. Path versioning caches separately per version, which is good. Query parameter versioning can pollute caches if not careful. I use the Vary header to tell caches to differentiate based on version.

res.set('Vary', 'Accept-Version');

Final Thought

API versioning is not about creating bureaucracy. It is about respect. Respect for your clients’ time. They built their systems around your promises. You have the right to improve, but you have the responsibility to give them time to adapt.

Pick a strategy, document it clearly, and stick to it. Use code patterns that reduce duplication. Set sunset dates and enforce them. Test every version. And when a client calls you confused, be patient. They are not dumb. They are just trying to make their app work.

I have made every mistake in the book. But I have also seen the relief on a client’s face when they realize they can migrate at their own pace. That is worth the extra few lines of code.

Now go version your API. Your future self will thank you.

Keywords: API versioning, REST API versioning, API versioning strategies, how to version an API, URI path versioning, query parameter versioning, API header versioning, content negotiation API, API deprecation strategy, API sunset policy, REST API design, API backward compatibility, API breaking changes, semantic versioning API, versioning RESTful APIs, API version control, custom request header versioning, Accept header versioning, API migration guide, API versioning tutorial, how to deprecate an API version, API versioning Node.js, Express.js API versioning, API versioning examples, API response versioning, REST API best design patterns, URL versioning API, API versioning vs content negotiation, managing multiple API versions, API versioning middleware, API caching versioning, Vary header API, API version sunset date, API client migration, API versioning JavaScript, API breaking change handling, versioned endpoint design, API version routing, graceful API deprecation, HTTP API versioning methods, API lifecycle management, API response structure changes, REST API evolution, API contract design, vnd media type versioning, API versioning pros and cons, REST API upgrade strategy, API version testing, automated API version testing



Similar Posts
Blog Image
Is Vite the Secret Weapon Every Web Developer Needs?

Unlocking Vite: Transforming Frontend Development with Speed and Efficiency

Blog Image
What's the Secret to Making Your Website Shine Like a Pro?

Mastering Web Vitals for a Seamless Online Experience

Blog Image
Mastering Web Animations: Boost User Engagement with Performant Techniques

Discover the power of web animations: Enhance user experience with CSS, JavaScript, and SVG techniques. Learn best practices for performance and accessibility. Click for expert tips!

Blog Image
Mastering State Management: Expert Strategies for Complex Web Applications

Explore effective state management in complex web apps. Learn key strategies, tools, and patterns for performant, maintainable, and scalable applications. Dive into Redux, Context API, and more.

Blog Image
Is Redux the Secret to Taming Your App's State Management Chaos?

Taming the State Beast: How Redux Brings Order to JavaScript Chaos

Blog Image
What Makes Flexbox the Secret Ingredient in Web Design?

Mastering Flexbox: The Swiss Army Knife of Modern Web Layouts