API versioning is one of those decisions that looks simple on day one and becomes a major headache on day 1000. The goal of versioning is to let you evolve your API over time without breaking existing clients — but the wrong strategy creates confusing URLs, complex infrastructure, and frustrated consumers who never know which version to use.
This guide compares the four main API versioning strategies, covers when you actually need versioning (and when you don't), and shows how to implement each approach cleanly in Node.js with Express.
When Do You Actually Need Versioning?
Not every API change requires a version bump. Distinguish between breaking changes (require a new version) and non-breaking changes (safe to deploy without a version bump):
Breaking changes — require new version:
- Removing a field from a response
- Renaming a field (e.g.,
user_name→username) - Changing a field's type (e.g., string → number)
- Changing HTTP method (e.g., PUT → PATCH)
- Removing an endpoint
- Changing authentication requirements
- Changing required fields in requests
Non-breaking changes — safe to ship without versioning:
- Adding new optional fields to responses
- Adding new optional request parameters
- Adding new endpoints
- Bug fixes that correct behavior to match documented intent
- Performance improvements
Many teams version too eagerly — every change bumps a version number, leading to v47 of their API that nobody uses. Version conservatively: only when you genuinely need to break backward compatibility.
Strategy 1: URL Path Versioning
URL path versioning embeds the version in the URL path:
GET /api/v1/users/123
GET /api/v2/users/123
POST /api/v1/products
Implementation in Express
// src/app.ts
import express from 'express';
import { v1Router } from './v1/router';
import { v2Router } from './v2/router';
const app = express();
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
export { app };
// src/v1/router.ts
import { Router } from 'express';
import { usersRouter as usersV1 } from './users/users.router';
import { productsRouter as productsV1 } from './products/products.router';
export const v1Router = Router();
v1Router.use('/users', usersV1);
v1Router.use('/products', productsV1);
// src/v2/router.ts — can reuse v1 handlers or override
import { Router } from 'express';
import { usersRouter as usersV2 } from './users/users.router'; // New v2 implementation
import { usersRouter as usersV1 } from '../v1/users/users.router'; // Reuse unchanged routes
export const v2Router = Router();
v2Router.use('/users', usersV2); // v2 has breaking changes
v2Router.use('/products', usersV1); // Products unchanged — reuse v1
Organizing Shared Code
// When v1 and v2 share most logic, use a service layer:
src/
├── v1/
│ └── users/
│ ├── users.router.ts # v1-specific routes and response shapes
│ └── users.controller.ts
├── v2/
│ └── users/
│ ├── users.router.ts # v2-specific routes and response shapes
│ └── users.controller.ts
└── services/
└── users.service.ts # Shared business logic (no versioning here)
Pros of URL versioning:
- Immediately visible in browser, logs, and curl commands
- Easy to route in proxies, CDNs, and API gateways
- Easy to test — just change the URL
- Most commonly understood by API consumers
Cons:
- Violates REST principle that URIs should identify resources, not versions
- Multiple URLs for the same resource can confuse caching
- URLs must change even if the resource didn't
Strategy 2: Header Versioning
Header versioning passes the version in a request header, keeping URLs clean:
GET /api/users/123
Accept-Version: v2
# Or using a custom header:
GET /api/users/123
API-Version: 2026-03-01
Implementation in Express
// src/middleware/versionRouter.ts
import { Request, Response, NextFunction, RequestHandler } from 'express';
type VersionedHandlers = {
[version: string]: RequestHandler;
default: RequestHandler;
};
export function versionRoute(handlers: VersionedHandlers): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
const version = req.headers['accept-version'] as string
|| req.headers['api-version'] as string
|| 'default';
const handler = handlers[version] ?? handlers['default'];
return handler(req, res, next);
};
}
// Usage:
import { getUserV1, getUserV2 } from './users.controller';
router.get('/:id', versionRoute({
v1: getUserV1,
v2: getUserV2,
default: getUserV2, // Default to latest
}));
Date-Based Header Versioning (Stripe's Approach)
Stripe uses date-based versioning: the version is a date string representing an API snapshot. When you sign up, you're locked to the Stripe API as it existed on that date. You can explicitly upgrade by changing the date:
// Server: track API version per request
app.use((req: Request, res: Response, next: NextFunction) => {
const stripeVersion = req.headers['stripe-version'] as string;
const apiDate = stripeVersion || process.env.DEFAULT_API_VERSION || '2026-01-01';
// Validate it's a known version
const knownVersions = ['2024-06-01', '2025-01-15', '2026-01-01'];
if (!knownVersions.includes(apiDate)) {
return res.status(400).json({
error: { code: 'INVALID_API_VERSION', message: `Unknown API version: ${apiDate}` }
});
}
req.apiVersion = apiDate;
res.setHeader('Stripe-Version', apiDate);
next();
});
// Route handler selects behavior based on version
router.get('/charges/:id', async (req, res) => {
const charge = await getCharge(req.params.id);
// Transform response based on version
if (req.apiVersion >= '2026-01-01') {
// New: amount is in decimal format
res.json({ id: charge.id, amount: charge.amount / 100 });
} else {
// Old: amount is in cents
res.json({ id: charge.id, amount: charge.amount });
}
});
Pros of header versioning:
- Clean URLs that don't change between versions
- Follows HTTP spec (Accept header content negotiation)
- Per-request version negotiation possible
Cons:
- Invisible in browser address bar — harder to share/debug
- Requires custom client configuration
- Harder to test with simple curl
- CDNs and caches may ignore custom headers
Strategy 3: Query Parameter Versioning
The version is passed as a query parameter:
GET /api/users/123?version=2
GET /api/users/123?api-version=2026-03-01
Implementation
// Simple query parameter version extraction
function getApiVersion(req: Request): string {
return (req.query.version as string)
|| (req.query['api-version'] as string)
|| 'v1'; // Default
}
app.use((req, res, next) => {
req.apiVersion = getApiVersion(req);
next();
});
Pros:
- Easy to test in a browser or with curl
- Version is visible in URLs
- Backward compatible default (no version = default version)
Cons:
- Query parameters are meant for filtering, not versioning
- Can conflict with other query parameters
- URLs look messier when combined with actual query params
- Breaks semantic URI principles
Strategy 4: Content Negotiation (Accept Header)
The most RESTful approach uses the HTTP Accept header with media type versioning:
GET /api/users/123
Accept: application/vnd.myapi.v2+json
# Or using parameters:
Accept: application/json;version=2
// Express content negotiation handler
router.get('/:id', (req, res, next) => {
const accept = req.headers['accept'] || '';
if (accept.includes('vnd.myapi.v2')) {
return getUserV2(req, res, next);
}
return getUserV1(req, res, next); // Default to v1
});
This approach is technically correct per HTTP spec but rarely used in practice because it's the most complex for clients to implement and the hardest to debug.
Versioning at the Field Level: Additive Changes
Before creating a whole new API version, consider additive changes that don't require version bumps:
// Instead of breaking change: rename "name" → "full_name" in v2
// Use additive approach: add "full_name" while keeping "name" (v1 compatible)
{
"id": "123",
"name": "John Doe", // Keep for backward compatibility
"full_name": "John Doe", // Add new field
"email": "john@example.com"
}
// Deprecate the old field with a response header
res.setHeader('Deprecation', 'true');
res.setHeader('Sunset', 'Sat, 31 Dec 2026 23:59:59 GMT');
res.setHeader('Link', '</api/v2/users>; rel="successor-version"');
Sunsetting Old Versions
API versioning without a sunset plan leads to maintaining v1 indefinitely. Use IETF's Sunset header to communicate deprecation:
// Middleware: add deprecation warnings to v1 responses
function v1DeprecationWarning(req: Request, res: Response, next: NextFunction) {
res.setHeader('Deprecation', '"2026-01-01"');
res.setHeader('Sunset', '"2026-12-31T23:59:59Z"');
res.setHeader('Link', '</api/v2>; rel="successor-version", <https://docs.example.com/migration>; rel="deprecation"');
next();
}
// Usage tracking — log which versions are still being called
app.use('/api/v1', (req, res, next) => {
metrics.increment('api.v1.request', {
path: req.path,
method: req.method,
});
next();
}, v1Router);
// Alert when usage drops below threshold (safe to sunset)
// Check metrics: if v1 traffic < 1% for 30 days, proceed with sunset
Making the Right Choice
Choose your strategy based on your context:
- Public API with many external consumers → URL path versioning (most discoverable and easiest for consumers)
- Internal API within a controlled ecosystem → Header versioning (clean URLs, controlled clients)
- API that evolves frequently (like Stripe) → Date-based header versioning (precise version control per customer)
- Simple API with occasional breaking changes → URL path versioning (pragmatic choice for most teams)
Whatever strategy you choose, the most important discipline is only versioning when you have to. Design your API response formats to be forward-compatible from the start: use objects not arrays for response envelopes (easier to add fields), use consistent field names, and document your deprecation policy before you write the first endpoint.
For more on API design, see our guides on REST vs GraphQL and REST API testing. Use our API Tester to test versioned endpoints with different headers.
Free Developer Tools
If you found this article helpful, check out DevToolkit — 40+ free browser-based developer tools with no signup required.
Popular tools: JSON Formatter · Regex Tester · JWT Decoder · Base64 Encoder
🛒 Get the DevToolkit Starter Kit on Gumroad — source code, deployment guide, and customization templates.
Top comments (0)