API Versioning in Node.js and TypeScript — Complete 2026 Guide
Advertisement
Introduction
Why API Versioning Matters
Breaking changes in a public API force every consumer to update simultaneously — an operational nightmare. Versioning decouples your release cycle from your clients. You can deploy a v2 endpoint while v1 continues serving existing consumers, with a documented deprecation timeline.
In 2026, URL path versioning remains the gold standard. Header-based versioning is popular in enterprise contexts. Both are valid; pick one and stick with it.
Strategy 1 — URL Path Versioning
The most explicit and cache-friendly approach. Version is visible in the URL, easy to route, easy to document.
// routes/v1/users.ts
import { Router } from 'express';
export const usersV1Router = Router();
usersV1Router.get('/', async (req, res) => {
const users = await db.users.findAll({ attributes: ['id', 'name', 'email'] });
res.json({ users });
});
// routes/v2/users.ts — added pagination + role field
export const usersV2Router = Router();
usersV2Router.get('/', async (req, res) => {
const page = Number(req.query.page ?? 1);
const limit = Number(req.query.limit ?? 20);
const { rows, count } = await db.users.findAndCountAll({
attributes: ['id', 'name', 'email', 'role', 'createdAt'],
offset: (page - 1) * limit,
limit,
});
res.json({
data: rows,
meta: { total: count, page, limit, pages: Math.ceil(count / limit) },
});
});
// app.ts
import { usersV1Router } from './routes/v1/users';
import { usersV2Router } from './routes/v2/users';
app.use('/api/v1/users', usersV1Router);
app.use('/api/v2/users', usersV2Router);Strategy 2 — Header-Based Versioning
Keeps URLs clean; version is communicated via a custom header. Useful in enterprise APIs where URL structure is locked.
import { Request, Response, NextFunction } from 'express';
type VersionedHandler = (req: Request, res: Response, next: NextFunction) => void;
function versionRouter(
versions: Record<string, VersionedHandler>,
defaultVersion = '1'
): VersionedHandler {
return (req, res, next) => {
const version = (req.headers['x-api-version'] as string) ?? defaultVersion;
const handler = versions[version];
if (!handler) {
return res.status(400).json({
error: `Unsupported API version: ${version}`,
supported: Object.keys(versions),
});
}
handler(req, res, next);
};
}
// Usage
app.get(
'/api/users',
versionRouter({
'1': getUsersV1,
'2': getUsersV2,
})
);Strategy 3 — Accept Header (Content Negotiation)
RFC-compliant approach used by GitHub API. Version lives in the Accept header MIME type.
function acceptVersionMiddleware(req: Request, res: Response, next: NextFunction) {
const accept = req.headers.accept ?? '';
// e.g. Accept: application/vnd.myapp.v2+json
const match = accept.match(/vnd\.myapp\.v(\d+)\+json/);
(req as any).apiVersion = match ? match[1] : '1';
next();
}
app.use(acceptVersionMiddleware);
app.get('/api/users', (req, res) => {
const version = (req as any).apiVersion as string;
if (version === '2') return getUsersV2(req, res);
return getUsersV1(req, res);
});Organizing Versioned Routes
src/
routes/
v1/
users.ts
posts.ts
index.ts ← mounts all v1 routers
v2/
users.ts
index.ts
app.ts// routes/v1/index.ts
import { Router } from 'express';
import { usersV1Router } from './users';
import { postsV1Router } from './posts';
export const v1Router = Router();
v1Router.use('/users', usersV1Router);
v1Router.use('/posts', postsV1Router);
// routes/v2/index.ts
import { Router } from 'express';
import { usersV2Router } from './users';
export const v2Router = Router();
v2Router.use('/users', usersV2Router);
// app.ts
import { v1Router } from './routes/v1';
import { v2Router } from './routes/v2';
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);Deprecation Policy
// Deprecation middleware — adds headers on deprecated versions
function deprecationWarning(sunsetDate: string, link: string) {
return (_req: Request, res: Response, next: NextFunction) => {
res.setHeader('Deprecation', 'true');
res.setHeader('Sunset', sunsetDate); // RFC 8594
res.setHeader('Link', `<${link}>; rel="successor-version"`);
next();
};
}
// Apply to v1 routes 6 months before removal
app.use('/api/v1',
deprecationWarning('2026-12-31', 'https://docs.yourapi.com/v2/migration'),
v1Router
);Shared Controllers with Version Adapters
// Shared business logic — no versioning here
async function fetchUsers(options: { page: number; limit: number; includeRole: boolean }) {
return db.users.findAll({
attributes: ['id', 'name', 'email', ...(options.includeRole ? ['role'] : [])],
offset: (options.page - 1) * options.limit,
limit: options.limit,
});
}
// Version adapters transform the shared result
export async function getUsersV1(req: Request, res: Response) {
const users = await fetchUsers({ page: 1, limit: 100, includeRole: false });
res.json({ users });
}
export async function getUsersV2(req: Request, res: Response) {
const page = Number(req.query.page ?? 1);
const limit = Number(req.query.limit ?? 20);
const users = await fetchUsers({ page, limit, includeRole: true });
res.json({ data: users, meta: { page, limit } });
}Common Mistakes
- Introducing breaking changes within the same version — a new required field is a breaking change
- Not communicating deprecation timelines — give consumers at least 6 months before removing a version
- Duplicating business logic in each version — share controllers and adapt the request/response layer only
- Using query string versioning (
?version=2) — breaks caching and is less discoverable - Removing deprecated versions without monitoring usage — check analytics to confirm zero traffic first
Best Practices
- Use URL path versioning (
/api/v1/) for public APIs — it is explicit, cache-friendly, and firewall-friendly - Only create a new version when making a breaking change — additive changes do not require a new version
- Add
Deprecation,Sunset, andLinkheaders to deprecated routes per RFC 8594 - Keep business logic in shared services; version only the request parsing and response shaping
- Document each version in Swagger/OpenAPI with accurate examples and changelog notes
Key Takeaways
- Breaking changes in a public API require a new version; additive changes (new optional fields) do not
- URL path versioning (
/api/v1/) is the most widely adopted strategy in 2026 — explicit and cache-friendly - Header-based versioning keeps URLs clean but requires clients to set custom request headers
- Organize versioned routes in
routes/v1/androutes/v2/directories for maintainability - Share business logic across versions; only version the HTTP adapter layer
- Announce deprecation at least 6 months before sunset with
DeprecationandSunsetheaders - Monitor traffic per version before removing it — assume some consumers never update
- Content negotiation (
Accept: application/vnd.app.v2+json) is RFC-compliant but less common in practice
Advertisement