Your APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. is a contract. The moment someone builds a mobile app, a third-party integration, or an internal service on top of your API, you have made a promiseWhat is promise?An object that represents a value you don't have yet but will get in the future, letting your code keep running while it waits.: "this will keep working." But software evolves. You need to add fields, change response shapes, deprecate endpoints. Versioning is how you evolve without breaking that promise.
The question is not whether to version, it is how.
/v1/ in your URLs. Add versioning when you have external consumers or independent release cycles, not before.URL path versioning
This is by far the most popular approach. You embed the version number directly in the URL:
// Express router with URL versioning
const v1Router = express.Router();
const v2Router = express.Router();
v1Router.get('/users', (req, res) => {
// V1: returns flat user object
const users = await db.users.findAll();
res.json(users.map(u => ({
id: u.id,
name: u.name,
email: u.email
})));
});
v2Router.get('/users', (req, res) => {
// V2: returns nested profile object
const users = await db.users.findAll({ include: ['profile'] });
res.json(users.map(u => ({
id: u.id,
name: u.name,
email: u.email,
profile: {
avatar: u.profile.avatar,
bio: u.profile.bio
}
})));
});
app.use('/v1', v1Router);
app.use('/v2', v2Router);The appeal is obvious: you can see the version in the URL, test it in a browser, and route traffic at the load balancerWhat is load balancer?A server that distributes incoming traffic across multiple backend servers so no single server gets overwhelmed. level. GitHub, Stripe, and Twilio all use this approach. CDNs and proxies cache /v1/users and /v2/users independently with zero configuration.
The downside is that every version creates a new set of routes. If you have 40 endpoints and only 3 changed between v1 and v2, you still need to maintain the full surface area of both versions, or build an internal routing layer that forwards unchanged endpoints from v2 to v1 handlers.
Header versioning
Instead of changing the URL, you use the Accept header to specify which version of the response you want:
app.get('/users', (req, res) => {
const accept = req.headers['accept'] || '';
if (accept.includes('application/vnd.myapi.v2+json')) {
// V2 response with nested profile
const users = await db.users.findAll({ include: ['profile'] });
return res.json(users.map(u => ({
id: u.id,
name: u.name,
profile: { avatar: u.profile.avatar }
})));
}
// Default to V1
const users = await db.users.findAll();
res.json(users.map(u => ({
id: u.id,
name: u.name,
email: u.email
})));
});This approach keeps your URLs clean, /users is always /users regardless of version. The Azure RESTWhat is rest?An architectural style for web APIs where URLs represent resources (nouns) and HTTP methods (GET, POST, PUT, DELETE) represent actions on those resources. APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. and parts of the GitHub API use this pattern. It follows HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. semantics more closely, since the Accept header is designed for content negotiation.
But it is harder to work with in practice. You cannot paste a versioned URL into a browser tab. Developers must set custom headers in Postman, curl, or their HTTP client, which adds friction. Caching becomes more complex because the URL alone no longer determines the response, you need Vary: Accept headers, and not all CDNs handle that well.
Query parameter versioning
The simplest approach: append the version as a query parameter.
app.get('/users', (req, res) => {
const version = parseInt(req.query.version) || 1;
if (version === 2) {
// V2 response
return res.json(await getUsersV2());
}
// Default to V1
res.json(await getUsersV1());
});Google and some AWS services use this pattern. It is easy to implement and easy to test, just change ?version=1 to ?version=2 in the URL. But it mixes versioning concerns with regular query parameters, which muddies the APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. surface. If a consumer forgets the parameter, they get the default version, which might not be what they expect.
?version=2 parameter, they silently get the default version. This causes bugs that are hard to trace because the request "works", just with the wrong response shape.Comparing the strategies
| Criteria | URL path (/v1/) | Header (Accept) | Query param (?version=) |
|---|---|---|---|
| Visibility | High, version in URL | Low, hidden in headers | Medium, in URL but optional |
| Browser testable | Yes | No (needs custom headers) | Yes |
| Caching | Simple (URL-based) | Complex (needs Vary header) | Simple (URL-based) |
| Routing complexity | Separate route trees | Conditional logic in handlers | Conditional logic in handlers |
| URL cleanliness | Cluttered (/v1/, /v2/) | Clean (same URL always) | Slightly cluttered |
| CDN/proxy support | Excellent | Requires configuration | Good |
| Industry adoption | Most common (Stripe, GitHub) | Less common (Azure, GitHub partial) | Moderate (Google, AWS) |
| Risk of consumer error | Low (explicit in URL) | Medium (wrong header = wrong version) | High (missing param = default) |
Vary: Accept on every response. Most CDNs cache by URL only, so two clients requesting different versions of /users get the same cached response. This causes intermittent bugs that are extremely hard to reproduce.Semantic versioningWhat is semantic versioning?A numbering system (major.minor.patch) that communicates whether a release contains breaking changes, new features, or bug fixes. for APIs
Even if your URL says /v2/, you need a more granular system to communicate change scopeWhat is scope?The area of your code where a variable is accessible; variables declared inside a function or block are invisible outside it.. Semantic versioning (SemVer) uses three numbers: major.minor.patch.
2.3.1
│ │ └── Patch: bug fixes, no behavior change
│ └──── Minor: new features, backward compatible
└────── Major: breaking changesIn practice, the URL version maps to the major version. Minor and patch versions are communicated through changelogs, response headers, or APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. documentation:
// Communicate the precise version in response headers
app.use((req, res, next) => {
res.setHeader('X-API-Version', '2.3.1');
next();
});The key rule: only major version bumps break existing integrations. If you follow SemVer correctly, a consumer on v2 can safely ignore minor and patch updates, their code will keep working.
When you don't need versioning
Versioning adds overhead. Before you set up /v1/ on day one, consider whether you actually need it:
Skip versioning when:
- Your APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. is internal (only your own frontend calls it), you deploy both sides simultaneously
- You are in early development (pre-launch, no external consumers yet)
- You have a single consumer that you control and can update at will
- Your API is behind a feature flagWhat is feature flag?A configuration toggle that lets you turn a feature on or off without redeploying your app, useful for gradual rollouts or instantly disabling broken functionality. system that already handles gradual rollouts
Add versioning when:
- Third-party developers build on your API
- Mobile apps use your API (you cannot force users to update)
- Multiple internal teams consume your API on their own release schedules
- Your API is a product (Stripe, Twilio, SendGrid)
Starting without versioning and adding it later is perfectly valid, and simpler than maintaining version infrastructure you do not need yet. The cost of adding /v1/ to your URLs retroactively is much lower than maintaining two version routers for an API that only you use.
Practical implementation pattern
Here is what AI typically generates for version routing, separate complete route files for each version, and the cleaner alternative:
Here is a clean pattern for URL versioning that avoids duplicating unchanged endpoints:
// Shared handlers that haven't changed between versions
const sharedHandlers = {
getHealth: (req, res) => res.json({ status: 'ok' }),
getProducts: (req, res) => { /* same in v1 and v2 */ }
};
// V1-specific handlers
const v1Handlers = {
getUsers: (req, res) => { /* flat response */ }
};
// V2-specific handlers
const v2Handlers = {
getUsers: (req, res) => { /* nested response */ }
};
function mountVersion(app, prefix, versionHandlers) {
const merged = { ...sharedHandlers, ...versionHandlers };
const router = express.Router();
router.get('/health', merged.getHealth);
router.get('/users', merged.getUsers);
router.get('/products', merged.getProducts);
app.use(prefix, router);
}
mountVersion(app, '/v1', v1Handlers);
mountVersion(app, '/v2', v2Handlers);This way, unchanged endpoints share the same code, and you only maintain version-specific handlers for endpoints that actually differ.