Implementing API Header Versioning in node.js
API versioning via headers is a powerful way to evolve your API without breaking existing clients. Here's a deep dive into what it is, why you'd use it, and a step-by-step guide to implementing it in Express. Why Header Versioning? 1.Separation of Concerns Versioning in headers keeps your URL space clean - clients still call /users instead of /v1/users or /v2/users. 2.Content Negotiation HTTP was designed around content negotiation. You can leverage the Accept header or a custom header (e.g. X-API-Version) to tell your server which variant of the response to emit. 3.Granular Control You can version on a per-resource or even per-field basis depending on header values. Versioning Strategies Strategy How It Works Pros Cons Accept Header Accept: application/vnd.myapp.v1+json Follows HTTP spec; cache-friendly Clients must set correct MIME type Custom Header X-API-Version: 2 Explicit, easy to read Non-standard header Best Practices Default Version Decide on a default (e.g. latest or oldest). Gracefully support clients that omit the header. Deprecation Policy Communicate upcoming removals, e.g. via Deprecation and Sunset headers. Semantic Versioning (optionally) Let major versions break backward compatibility; minor versions may add fields without breaking. Documentation & Testing Clearly document required headers per endpoint. Automate tests for each version. Implementing Header Versioning in Express Project Layout src ├── core │ ├── server.ts │ └── app.ts ├── comon │ ├── middlewares │ │ └── extractVersion.ts │ └── helpers │ └── loadRouter.ts └── api ├── versions │ └── v1.ts └── index.ts main.ts First, we need a middleware to extract the API version from the request headers. It first checks the X-API-Version header, and if it's not present, it tries to extract the version from the 'Accept' header using a regex. If no version is found, it defaults to the provided defaultVersion or '1'. // src/middleware/extractVersion.ts import type { NextFunction, Request, Response } from 'express'; /** * Middleware to extract the API version from the request headers. * It first checks the 'X-API-Version' header, and if it's not present, * it tries to extract the version from the 'Accept' header using a regex. * If no version is found, it defaults to the provided defaultVersion or '1'. * * @param {string} [defaultVersion='1'] - The default API version to use if none is found. * @returns {Function} A middleware function to handle version extraction. * * @example * app.use(extractVersion('2')); // Sets the default version to '2' if not provided */ export function extractVersion(defaultVersion = '1') { return (req: Request, res: Response, next: NextFunction) => { let version = req.headers['x-api-version']; if (version && (!/^\d+$/.test(version as string) || version === '0')) { res.status(400).json({ error: 'Invalid X-API-Version header format', }); return; } if (!version) { const accept = req.get('accept') || ''; const m = accept.match(/application\/vnd\.myapp\.v(\d+)\+json/); version = m ? m[1] : undefined; } req.apiVersion = (version as string) || defaultVersion; next(); }; } Finally, the API version can be accessed via req.apiVersion In the next step, we need a helper that loads the appropriate router based on the API version specified in the request. Plugin Loader (Dynamic Version Wiring) The helper selects the router version from the available versions based on the apiVersion property in the request. If no version is specified, version 1 will be used as the default. If the specified version doesn't exist, the default version will be used. // ./src/common/helpers/loadRouter.ts import routerV1 from '#app/api/versions/v1'; export function loadRouter(router: Router) { const versions: Record = { '1': routerV1, }; return (req: Request, res: Response, next: NextFunction) => { const v = (req.apiVersion as string) ?? '1'; const handler = versions[v] ?? versions['1']; if (handler) { return handler(req, res, next); } }; } for each version we define a separate router: routerV1 // /src/api/versions/v1.ts import { Router } from 'express'; const router = Router(); export default router; After that, we need to define our API router: // src/api/index.ts import { Router } from 'express'; import { loadRouter } from '#app/common/helpers/loadRouter'; export const router = loadRouter(Router()); This code uses the loadRouter helper function to configure and load the router, allowing for modular route loading in the application. The Router () from express is passed as the parameter to define the routes for the application. Next, we'll hook up the API router to our main app router: import express from 'express'; import { router as apiRouter } from '#app/api/'; export const app = expres

API versioning via headers is a powerful way to evolve your API without breaking existing clients.
Here's a deep dive into what it is, why you'd use it, and a step-by-step guide to implementing it in Express.
Why Header Versioning?
1.Separation of Concerns
Versioning in headers keeps your URL space clean - clients still call /users instead of /v1/users or /v2/users.
2.Content Negotiation
HTTP was designed around content negotiation. You can leverage the Accept header or a custom header (e.g. X-API-Version) to tell your server which variant of the response to emit.
3.Granular Control
You can version on a per-resource or even per-field basis depending on header values.
Versioning Strategies
Strategy | How It Works | Pros | Cons |
---|---|---|---|
Accept Header | Accept: application/vnd.myapp.v1+json |
Follows HTTP spec; cache-friendly | Clients must set correct MIME type |
Custom Header | X-API-Version: 2 |
Explicit, easy to read | Non-standard header |
Best Practices
- Default Version
Decide on a default (e.g. latest or oldest). Gracefully support clients that omit the header.
- Deprecation Policy
Communicate upcoming removals, e.g. via Deprecation and Sunset headers.
- Semantic Versioning (optionally)
Let major versions break backward compatibility; minor versions may add fields without breaking.
- Documentation & Testing
Clearly document required headers per endpoint. Automate tests for each version.
Implementing Header Versioning in Express
Project Layout
src
├── core
│ ├── server.ts
│ └── app.ts
├── comon
│ ├── middlewares
│ │ └── extractVersion.ts
│ └── helpers
│ └── loadRouter.ts
└── api
├── versions
│ └── v1.ts
└── index.ts
main.ts
First, we need a middleware to extract the API version from the request headers.
It first checks the X-API-Version header, and if it's not present, it tries to extract the version from the 'Accept' header using a regex.
If no version is found, it defaults to the provided defaultVersion or '1'.
// src/middleware/extractVersion.ts
import type { NextFunction, Request, Response } from 'express';
/**
* Middleware to extract the API version from the request headers.
* It first checks the 'X-API-Version' header, and if it's not present,
* it tries to extract the version from the 'Accept' header using a regex.
* If no version is found, it defaults to the provided defaultVersion or '1'.
*
* @param {string} [defaultVersion='1'] - The default API version to use if none is found.
* @returns {Function} A middleware function to handle version extraction.
*
* @example
* app.use(extractVersion('2')); // Sets the default version to '2' if not provided
*/
export function extractVersion(defaultVersion = '1') {
return (req: Request, res: Response, next: NextFunction) => {
let version = req.headers['x-api-version'];
if (version && (!/^\d+$/.test(version as string) || version === '0')) {
res.status(400).json({
error: 'Invalid X-API-Version header format',
});
return;
}
if (!version) {
const accept = req.get('accept') || '';
const m = accept.match(/application\/vnd\.myapp\.v(\d+)\+json/);
version = m ? m[1] : undefined;
}
req.apiVersion = (version as string) || defaultVersion;
next();
};
}
Finally, the API version can be accessed via req.apiVersion
In the next step, we need a helper that loads the appropriate router based on the API version specified in the request.
Plugin Loader (Dynamic Version Wiring)
The helper selects the router version from the available versions based on the apiVersion property in the request.
If no version is specified, version 1 will be used as the default. If the specified version doesn't exist, the default version will be used.
// ./src/common/helpers/loadRouter.ts
import routerV1 from '#app/api/versions/v1';
export function loadRouter(router: Router) {
const versions: Record<string, Router> = {
'1': routerV1,
};
return (req: Request, res: Response, next: NextFunction) => {
const v = (req.apiVersion as string) ?? '1';
const handler = versions[v] ?? versions['1'];
if (handler) {
return handler(req, res, next);
}
};
}
for each version we define a separate router: routerV1
// /src/api/versions/v1.ts
import { Router } from 'express';
const router = Router();
export default router;
After that, we need to define our API router:
// src/api/index.ts
import { Router } from 'express';
import { loadRouter } from '#app/common/helpers/loadRouter';
export const router = loadRouter(Router());
This code uses the loadRouter helper function to configure and load the router, allowing for modular route loading in the application.
The Router () from express is passed as the parameter to define the routes for the application.
Next, we'll hook up the API router to our main app router:
import express from 'express';
import { router as apiRouter } from '#app/api/';
export const app = express();
/**
* Mount the main API router.
*
* This router handles all API endpoints, typically grouped by version and feature.
* Should be mounted after global middlewares (like extractVersion and responseMiddleware).
*/
app.use('/api', apiRouter);
This way of loading versions of our API is based on the plugin architecture
You can also use the Strategy Pattern to design your controllers and services.
The Strategy Pattern is a behavioral design pattern that lets you define a family of interchangeable algorithms (strategies), encapsulate each one behind a common interface, and make them interchangeable at runtime. Instead of hard-coding specific behaviors with if/else or switch statements, you "inject" the strategy you need.
Designing Services and Controllers with the Strategy Pattern
Once you've wired up header-based version discovery and a plugin loader for each API "version," you still need a way to vary your business logic (services) and your HTTP handlers (controllers) by version without littering if (version === 'v1')… checks everywhere.
Enter the Strategy Pattern: define a common interface, implement each version as a "strategy," then select at runtime.
Define a common strategy interface
Create a shared interface for the operation you want to vary - in this example, a simple UserLookup service:
// src/services/userLookup.ts
export interface IUserLookup {
findById(id: string): Promise<User>;
}
Implement each version as a concrete strategy
Now implement two versions - v1 returns a "legacy" user object, while v2 returns the new schema:
// plugins/v1/services/userLookupV1.ts
import { IUserLookup } from 'src/services/userLookup';
export class UserLookupV1 implements IUserLookup {
async findById(id: string) {
// legacy data shape
const { first, last, email } = await db.queryLegacyUser(id);
return { name: `${first} ${last}`, email };
}
}
// plugins/v2/services/userLookupV2.ts
import { IUserLookup } from 'src/services/userLookup';
export class UserLookupV2 implements IUserLookup {
async findById(id: string) {
// new table, extra fields
return await db.queryUser({ id, include: ['avatarUrl','preferences'] });
}
}
Create a "context" that selects the right strategy
Use the version you parsed from the Accept header to pick and cache a strategy instance:
// src/strategies/userLookupContext.ts
import { IUserLookup } from 'src/services/userLookup';
import { UserLookupV1 } from 'plugins/v1/services/userLookupV1';
import { UserLookupV2 } from 'plugins/v2/services/userLookupV2';
const strategies: Record<string, new () => IUserLookup> = {
'v1': UserLookupV1,
'v2': UserLookupV2,
};
export class UserLookupContext {
private strategy: IUserLookup;
constructor(version: string) {
const Strategy = strategies[version];
if (!Strategy) throw new Error(`Unsupported API version: ${version}`);
this.strategy = new Strategy();
}
findById(id: string) {
return this.strategy.findById(id);
}
}
Wire your controller to use the context
In your Express controller, inject the versioned context based on the middleware's parsed header:
// src/controllers/userController.ts
import { Request, Response, NextFunction } from 'express';
import { UserLookupContext } from 'src/strategies/userLookupContext';
export async function getUser(req: Request, res: Response, next: NextFunction) {
try {
// assume version extraction middleware set req.apiVersion
const ctx = new UserLookupContext(req.apiVersion);
const user = await ctx.findById(req.params.id);
res.json({ data: user });
} catch (err) {
next(err);
}
}
Benefits
- Open/Closed:
Adding v3 means writing a new UserLookupV3 class and registering it-no changes to existing code.
- Single Responsibility:
Each strategy focuses purely on its version's logic.
- Testability:
You can unit-test each version in isolation and even mock the context to inject a fake strategy.
- Clarity:
Controllers stay slim, delegating all variation to the strategy layer.
you can implement the Strategy Pattern in a purely functional way, avoiding classes altogether. The core idea is the same: you define a common "shape" for your strategy functions, register each version's implementation, and pick the right one at runtime.
other posts:
Let's connect!!: