How to implement API versioning in Express.js?
API versioning - serving multiple API versions from the same Express server so existing clients keep working when you ship breaking changes.
Theory
TL;DR
- Think of it like hotel room keys: old key opens room 101 (v1), new key opens room 102 (v2), same front desk handles both
- Three main strategies: URL path (
/api/v1), request headers (Accept-Version), query params (?version=2) - URL path for public APIs; headers for mobile or internal clients; query params only for prototypes
- Always add deprecation headers (
Sunset,Deprecation) when phasing a version out - Share business logic in
services/, version only the API layer
Quick example
const express = require('express');
const app = express();
// v1: flat user object (legacy clients expect this)
app.use('/api/v1/users', (req, res) => {
res.json([{ name: 'John Doe' }]);
});
// v2: split name fields (breaking change)
app.use('/api/v2/users', (req, res) => {
res.json([{ firstName: 'John', lastName: 'Doe' }]);
});
app.listen(3000);GET /api/v1/users -> [{ "name": "John Doe" }]
GET /api/v2/users -> [{ "firstName": "John", "lastName": "Doe" }]Both versions run in parallel. Old clients hit /v1 and get what they expect. New clients hit /v2 and get the updated shape.
URL path vs header versioning
URL path versioning ties the version to the route. You can test it in a browser, share the URL in Slack, and proxy it through a CDN with no extra config. Header versioning keeps URLs clean by reading Accept-Version or Api-Version from request headers, which works well for mobile apps that control every request, but it is harder to test manually and requires Vary: Accept-Version for caches.
When to use each strategy
- Public REST API with many clients: URL path (
/api/v1,/api/v2) - Mobile or internal apps: header versioning, client controls the header
- Quick prototype or legacy client you cannot update: query param (
?version=2) - Enterprise API gateway with semver ranges: custom middleware using the
semverlibrary
Comparison table
| Strategy | Example | Cache-friendly | When to use |
|---|---|---|---|
| URL path | /api/v1/users | Yes | Public APIs (Stripe, Twilio) |
| Header | Accept-Version: 2.0.0 | Only with Vary | Mobile, internal APIs (GitHub) |
| Query param | /api/users?version=2 | No | Prototypes, legacy clients |
| Middleware semver | Custom routing | Configurable | Enterprise API gateways |
Production structure: shared logic, versioned routes
The biggest mistake teams make is duplicating entire controllers per version. Split the layers instead:
src/
├── routes/
│ ├── v1/
│ │ ├── index.js
│ │ └── users.routes.js
│ └── v2/
│ ├── index.js
│ └── users.routes.js <- only changed endpoints
├── controllers/
│ ├── v1/
│ └── v2/
└── services/ <- shared business logic, no versioning hereIf nothing changed in a module between versions, re-export v1 directly:
// routes/v2/products.routes.js
// No changes from v1 - re-use the same router
module.exports = require('../v1/products.routes');You are versioning the interface, not the database queries or domain logic.
Header versioning with semver
When clients send version strings like 1.2.0 or ^2.0.0, plain string comparison breaks. Use the semver library:
const express = require('express');
const semver = require('semver');
const app = express();
app.use('/api/users', (req, res, next) => {
const version = req.headers['accept-version'] || '1.0.0';
if (semver.satisfies('2.0.0', version)) return v2Handler(req, res, next);
if (semver.satisfies('1.0.0', version)) {
console.warn('v1 deprecated, please upgrade to v2');
return v1Handler(req, res, next);
}
res.status(400).json({ error: 'Invalid version' });
});
const v1Handler = (req, res) => res.json({ users: [{ name: 'John' }] });
const v2Handler = (req, res) => res.json({ data: [{ firstName: 'John' }] });Accept-Version: 1.2.0 -> v1 response + deprecation log
Accept-Version: 2.0.0 -> v2 response
Accept-Version: 3.0.0 -> 400 errorDeprecation headers
When v1 is going away, clients need to know ahead of time. Add headers to every v1 response:
function deprecationWarning(req, res, next) {
res.set('Deprecation', 'true');
res.set('Sunset', 'Sat, 01 Jun 2026 00:00:00 GMT');
res.set('Link', '</api/v2>; rel="successor-version"');
next();
}
app.use('/api/v1', deprecationWarning, v1Router);The Sunset header is an IETF standard (RFC 8594). Most HTTP clients and API monitoring tools read it automatically. Give clients 12-18 months between the sunset announcement and the actual shutdown.
How Express routes this internally
Express uses a layered middleware stack: each app.use() or app.get() call adds a Layer object. When a request arrives, Express walks the stack and stops at the first matching layer. Versioned paths like /api/v1 and /api/v2 match against separate Router instances and never interfere with each other. Node's HTTP parser extracts the path and headers before Express sees the request, so both URL and header versioning work cleanly at the middleware level. One thing to watch: if v1 and v2 share identical handlers, V8 inlines and optimizes them. But if the logic diverges heavily, you grow the heap. Keep shared code in services/, not in route handlers.
Common mistakes
Sharing the full controller across versions
// Wrong: both versions point to the same handler
const getUsers = (req, res) => res.json([{ firstName: 'John' }]);
app.use('/api/v1', getUsers); // v1 clients expect { name }, not { firstName }
app.use('/api/v2', getUsers);v1 clients break without any warning. Fix: fork the handler or use a transformer that maps v2 fields back to the v1 format: v1Response.name = v2Response.firstName + ' ' + v2Response.lastName.
No deprecation headers
Running v1 without Sunset headers is how you end up with v1 handling 80% of traffic two years later. Add the headers, log every v1 request with something like Prometheus, and watch the metric drop before you pull the plug.
Versioning non-breaking changes
Adding an optional field to a response is not a breaking change. Creating a new version for it adds complexity with no benefit. Version only when the change would break existing clients: renamed fields, removed fields, changed types, different auth flows.
Forgetting Vary: Accept-Version with header versioning
Without it, CDNs and reverse proxies cache one version and serve it to all clients regardless of the header they send. Always set res.set('Vary', 'Accept-Version') in the versioning middleware.
Real-world usage
- Stripe:
/v1/charges- URL path, unchanged since 2011, original clients still work - GitHub:
Accept: application/vnd.github.v3+json- header versioning across 20+ years - Twilio:
/2010-04-01/Accounts- URL with a date-based version string - Kubernetes API: custom middleware for semver range matching across dozens of API groups
- Express Gateway: open-source API gateway with built-in versioning middleware
Follow-up questions
Q: What are the tradeoffs between URL path and header versioning?
A: URL versioning is explicit and cache-friendly but forces clients to update their URLs. Header versioning keeps paths stable but is harder to test manually and needs Vary headers for correct caching. Use URL for browser-facing or public APIs, headers for mobile or internal clients.
Q: How do you migrate clients from v1 to v2?
A: Run both versions in parallel for 12-18 months. Add Deprecation and Sunset headers to v1 responses from day one. Proxy v1 requests to v2 handlers through a response transformer where possible. Track v1 usage in metrics to know when it is actually safe to turn it off.
Q: How do you handle semver ranges like ^1.2.0 in Express?
A: Use the semver library. Check semver.satisfies(currentVersion, requestedRange) in middleware and route to the correct handler. Minor and patch versions can share the same router; only major bumps need a separate branch.
Q: What if v2 needs to reuse v1 data but return a different shape?
A: Keep a shared service layer that returns a neutral internal format. Each version's controller applies its own serializer to produce the right response shape. Never call v1's controller from v2's route.
Q: How do you scale to 10 or more versions without the codebase exploding? (senior)
A: Use a dynamic route loader that scans ./routes/v* directories at startup. Keep one shared model layer and attach versioned serializers to each model class. Deprecate aggressively: support at most two active versions at any time. Document the lifecycle in an OpenAPI spec per version and automate the deprecation timeline in CI.
Examples
Basic: URL path versioning with shared validation
const express = require('express');
const app = express();
// Shared validation middleware used by both versions
const validateUserId = (req, res, next) => {
if (!req.params.id) return res.status(400).json({ error: 'Missing user ID' });
next();
};
// v1: flat user object
app.get('/api/v1/users/:id', validateUserId, (req, res) => {
res.json({
id: req.params.id,
name: 'John Doe',
email: 'john@example.com'
});
});
// v2: nested profile with avatar (breaking change)
app.get('/api/v2/users/:id', validateUserId, (req, res) => {
res.json({
id: req.params.id,
profile: { name: 'John Doe', email: 'john@example.com' },
avatar: 'https://example.com/avatar.jpg'
});
});
app.listen(3000);GET /api/v1/users/123
-> { "id": "123", "name": "John Doe", "email": "john@example.com" }
GET /api/v2/users/123
-> { "id": "123", "profile": { "name": "John Doe", "email": "john@example.com" }, "avatar": "https://example.com/avatar.jpg" }validateUserId is shared. Only the response shape differs. Shared validation, versioned serialization: this is the correct split.
Intermediate: Router-based versioning with re-exports
// routes/v1/users.js
const router = require('express').Router();
router.get('/', (req, res) => res.json([{ name: 'John Doe' }]));
module.exports = router;// routes/v2/users.js
const router = require('express').Router();
router.get('/', (req, res) => res.json([{ firstName: 'John', lastName: 'Doe' }]));
module.exports = router;// routes/v2/index.js
const router = require('express').Router();
router.use('/users', require('./users')); // updated in v2
router.use('/products', require('../v1/products')); // unchanged, re-use v1
module.exports = router;// app.js
app.use('/api/v1', require('./routes/v1'));
app.use('/api/v2', require('./routes/v2'));Re-exporting unchanged v1 routes in v2 means you never maintain two copies of the same logic. When products change in a future v3, you fork only that one file.
Advanced: Deprecation middleware with usage logging
const express = require('express');
const app = express();
// Attaches deprecation headers and logs every v1 hit for monitoring
function v1Deprecation(req, res, next) {
res.set('Deprecation', 'true');
res.set('Sunset', 'Sat, 01 Jun 2026 00:00:00 GMT');
res.set('Link', '</api/v2>; rel="successor-version"');
// Replace console.warn with your metrics client (e.g. Prometheus counter)
console.warn({
event: 'v1_request',
path: req.path,
timestamp: new Date().toISOString()
});
next();
}
const v1Router = require('./routes/v1');
const v2Router = require('./routes/v2');
app.use('/api/v1', v1Deprecation, v1Router);
app.use('/api/v2', v2Router);
app.listen(3000);This gives you visibility into which v1 endpoints still get traffic. Once the v1_request count drops to zero, you can remove the router without risk. Teams that skip this step end up supporting v1 forever because nobody knows if someone is still using it.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.