You shipped v1 of your API. Now you need to change something that breaks existing clients. What do you do?
This is where API versioning comes in. And theres more then one way to do it.
Why Version At All?
Without versioning, you either break old clients or never improve your API. Neither is great.
Pattern 1: URL Path Versioning
Most common approach. Version goes in the URL:
GET /v1/users
GET /v2/users
// Express example
app.use('/v1', v1Router);
app.use('/v2', v2Router);
Pros:
- Super obvious which version youre using
- Easy to route and cache
- Simple to deprecate
Cons:
- URL pollution
- Clients need to update URLs when upgrading
This is what Stripe, GitHub, and most major APIs use. If its good enough for them, its probably good enough for you.
Pattern 2: Header Versioning
Version lives in a custom header:
GET /users
Accept-Version: v2
app.use((req, res, next) => {
const version = req.headers['accept-version'] || 'v1';
req.apiVersion = version;
next();
});
Pros:
- Clean URLs
- More RESTful (same resource, different representation)
Cons:
- Harder to test in browser
- Easy to forget the header
- Caching is trickier
GitHub uses this as an option. Works well if you have sophisticated clients.
Pattern 3: Query Parameter
Version in the query string:
GET /users?version=2
Pros:
- Easy to test
- No URL path changes
Cons:
- Looks messy
- Can conflict with other params
I dont love this one, but it works in a pinch.
Which One to Pick?
My recommendation: URL path versioning for public APIs. Its the most foolproof and everyone understands it.
Handling Multiple Versions
Dont duplicate everything. Share what you can:
// Shared business logic
const userService = {
getUser: async (id) => { /* ... */ }
};
// v1 response format
const v1Transform = (user) => ({
id: user.id,
name: user.fullName
});
// v2 response format
const v2Transform = (user) => ({
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
createdAt: user.createdAt
});
// Routes use same service, different transforms
v1Router.get('/users/:id', async (req, res) => {
const user = await userService.getUser(req.params.id);
res.json(v1Transform(user));
});
v2Router.get('/users/:id', async (req, res) => {
const user = await userService.getUser(req.params.id);
res.json(v2Transform(user));
});
Deprecation Strategy
- Announce deprecation - Add headers, update docs
- Sunset period - Return warnings but keep working
- Remove - Return 410 Gone
// Deprecation header
res.set('Deprecation', 'true');
res.set('Sunset', 'Sat, 01 Jun 2025 00:00:00 GMT');
res.set('Link', '</v2/users>; rel="successor-version"');
Further Reading
- Stripe's API Versioning - They handle this beautifully
- Semantic Versioning - Good principles even for APIs
Version early, communicate changes clearly, and give clients time to migrate. Breaking changes are inevitable - how you handle them determines whether clients trust you.
