Why Version APIs?
APIs evolve over time. Breaking changes (removing fields, changing response format) must not break existing clients.
Client A (v1) API Server Client B (v2)
│ │ │
│── GET /api/v1/users ─→│ │
│←── { name, email } ──│ │
│ │ │
│ │ │── GET /api/v2/users ──→│
│ │ │←── { name, email, │
│ │ │ phone, avatar } ──│
Versioning Strategies
| Strategy | How | Example | Pros | Cons |
|---|---|---|---|---|
| URL Prefix | Version in URL path | /api/v1/users | Explicit, easy to route | Pollutes URLs |
| Header | Custom header | Accept: application/vnd.myapp.v2+json | Clean URLs | Harder to test in browser |
| Query Param | Version in query | /api/users?version=2 | Simple | Caching issues, not RESTful |
1. URL Prefix Versioning (Recommended)
Most common and explicit approach.
Directory Structure
routes/
├── v1/
│ ├── users.js
│ └── posts.js
├── v2/
│ ├── users.js
│ └── posts.js
└── index.js
Implementation
// routes/v1/users.js
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => {
res.json({
version: 'v1',
users: [
{ id: 1, name: 'Alice', email: 'alice@example.com' }
]
});
});
module.exports = router;
// routes/v2/users.js
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => {
res.json({
version: 'v2',
users: [
{
id: 1,
name: 'Alice',
email: 'alice@example.com',
phone: '+1-555-0123', // New in v2
avatar: '/avatars/alice.jpg' // New in v2
}
]
});
});
module.exports = router;
// routes/index.js
const express = require('express');
const router = express.Router();
router.use('/v1/users', require('./v1/users'));
router.use('/v2/users', require('./v2/users'));
// Default to latest version
router.use('/users', require('./v2/users'));
module.exports = router;
// index.js
const express = require('express');
const app = express();
app.use('/api', require('./routes'));
// Clients can call:
// GET /api/v1/users → v1 response
// GET /api/v2/users → v2 response
// GET /api/users → latest (v2) response
Router Factory Pattern
// helpers/createVersionedRouter.js
function createVersionedRouter(version, routes) {
const router = express.Router();
for (const [method, path, handler] of routes) {
router[method](path, handler);
}
// Add version info to responses
router.use((req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body) => {
if (body && typeof body === 'object') {
body.apiVersion = version;
}
return originalJson(body);
};
next();
});
return router;
}
// routes/v1/users.js
module.exports = createVersionedRouter('1.0', [
['get', '/', listUsers],
['post', '/', createUser],
['get', '/:id', getUser]
]);
// routes/v2/users.js - same pattern, different handler
module.exports = createVersionedRouter('2.0', [
['get', '/', listUsersV2],
['post', '/', createUserV2],
['get', '/:id', getUserV2]
]);
Express Router with Version Prefix
// routes/v1.js
const router = require('express').Router();
router.use('/users', require('./v1/users'));
router.use('/posts', require('./v1/posts'));
module.exports = router;
// routes/v2.js
const router = require('express').Router();
router.use('/users', require('./v2/users'));
router.use('/posts', require('./v2/posts'));
module.exports = router;
// index.js
const express = require('express');
const app = express();
app.use('/api/v1', require('./routes/v1'));
app.use('/api/v2', require('./routes/v2'));
// Redirect /api to latest version
app.use('/api', (req, res) => {
res.redirect(301, '/api/v2' + req.path);
});
2. Header-Based Versioning
// middleware/versionResolver.js
function versionResolver(req, res, next) {
const acceptHeader = req.headers['accept'] || '';
const versionMatch = acceptHeader.match(/application\/vnd\.myapp\.v(\d+)\+json/);
if (versionMatch) {
req.apiVersion = parseInt(versionMatch[1]);
} else {
req.apiVersion = 1; // Default
}
next();
}
// Usage in controllers
app.get('/api/users', versionResolver, (req, res) => {
if (req.apiVersion >= 2) {
return res.json(usersV2()); // Enhanced response
}
res.json(usersV1()); // Basic response
});
Client Request
# v1
curl -H "Accept: application/vnd.myapp.v1+json" http://localhost:3000/api/users
# v2
curl -H "Accept: application/vnd.myapp.v2+json" http://localhost:3000/api/users
# No header → default v1
curl http://localhost:3000/api/users
3. Query Parameter Versioning
// middleware/queryVersionResolver.js
function queryVersionResolver(req, res, next) {
req.apiVersion = parseInt(req.query.version) || 1;
next();
}
app.use('/api', queryVersionResolver);
app.get('/api/users', (req, res) => {
if (req.apiVersion >= 2) {
return res.json(usersV2());
}
res.json(usersV1());
});
curl "http://localhost:3000/api/users?version=1"
curl "http://localhost:3000/api/users?version=2"
Migrating Between Versions
Adding Fields (Backward Compatible)
// v1 user response
{ "id": 1, "name": "Alice", "email": "alice@test.com" }
// v2 - adds fields, keeps old ones
{ "id": 1, "name": "Alice", "email": "alice@test.com", "phone": "+1-555-0123" }
Removing Fields (Breaking Change)
// Deprecate field in v2, remove in v3
// v1 includes 'password_hash' (bad, but legacy)
// v2 includes it as deprecated
// v3 removes it
router.get('/:id', (req, res) => {
const user = getUser(req.params.id);
if (req.apiVersion >= 3) {
const { password_hash, ...safeUser } = user;
return res.json({ success: true, data: safeUser });
}
if (req.apiVersion >= 2) {
const { password_hash, ...safeUser } = user;
res.warning = 'password_hash will be removed in v3';
return res.json({ success: true, data: safeUser });
}
// v1 - full response (including legacy fields)
res.json({ success: true, data: user });
});
Versioning in the Controller
// controllers/userController.js
class UserController {
// Shared logic
async list(req, res, next) {
try {
const users = await User.find();
const version = req.apiVersion || 1;
// Transform based on version
const data = users.map(user => this.serialize(user, version));
res.json({ success: true, count: data.length, data });
} catch (err) { next(err); }
}
serialize(user, version) {
const base = {
id: user._id,
name: user.name,
email: user.email
};
if (version >= 2) {
base.phone = user.phone;
base.avatar = user.avatar;
}
if (version >= 3) {
base.role = user.role;
base.lastLogin = user.lastLogin;
}
return base;
}
}
module.exports = new UserController();
Version Deprecation
Communicate deprecation via response headers:
function deprecationMiddleware(minVersion, sunsetDate) {
return (req, res, next) => {
if (req.apiVersion < minVersion) {
res.set('Warning', `299 - "This API version is deprecated. Upgrade to v${minVersion}+"`);
res.set('Sunset', sunsetDate);
res.set('Link', `<${req.baseUrl}>; rel="successor-version"`);
}
next();
};
}
// Deprecate v1, sunset in 3 months
app.use('/api/v1', deprecationMiddleware(2, 'Sat, 13 Sep 2026 00:00:00 GMT'));
Complete Versioned API Setup
const express = require('express');
const app = express();
app.use(express.json());
// v1 routes
const v1Router = express.Router();
v1Router.use('/users', require('./routes/v1/users'));
v1Router.use('/posts', require('./routes/v1/posts'));
app.use('/api/v1', v1Router);
// v2 routes
const v2Router = express.Router();
v2Router.use('/users', require('./routes/v2/users'));
v2Router.use('/posts', require('./routes/v2/posts'));
v2Router.use('/products', require('./routes/v2/products')); // New in v2
app.use('/api/v2', v2Router);
// Default to latest
app.use('/api', v2Router);
// Version info endpoint
app.get('/api/versions', (req, res) => {
res.json({
versions: ['v1', 'v2'],
latest: 'v2',
deprecated: ['v1'],
sunset: { v1: '2026-09-13' }
});
});
app.listen(3000);
Key Takeaways
- URL prefix versioning (
/api/v1/,/api/v2/) is the most common and explicit approach - Keep old versions running alongside new ones - never break existing clients
- Add fields is backward compatible; remove/rename fields is breaking
- Use deprecation headers (
Warning,Sunset,Link) to communicate version lifecycle - Organize route files by version (
routes/v1/,routes/v2/) - Consider a router factory to reduce boilerplate across versions
- Provide a
/api/versionsendpoint to list available versions - Default unversioned requests to the latest version