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.