API Versioning Strategies: When and How to Version
Every API evolves — the question is whether you version through URL paths, headers or query parameters, and how you ship breaking changes safely.

Your API's first version will need to change. Fields get renamed, response structures evolve, and deprecated endpoints need to be removed. The question isn't whether to version — it's how to introduce changes without breaking every client that depends on the current contract. The versioning strategy you choose affects your URL structure, routing logic, documentation, and the long-term maintenance burden of supporting multiple versions.
The Three Approaches
URL Path Versioning
The most explicit approach. The version is part of the URL, visible in every request.
// URL path versioning — /api/v1/users, /api/v2/users
import express from "express";
const app = express();
// V1 routes
const v1Router = express.Router();
v1Router.get("/users", async (req, res) => {
const users = await getUsers();
// V1 returns flat structure
res.json(users.map(u => ({
id: u.id,
name: u.firstName + " " + u.lastName,
email: u.email,
})));
});
// V2 routes
const v2Router = express.Router();
v2Router.get("/users", async (req, res) => {
const users = await getUsers();
// V2 returns nested structure with pagination
res.json({
data: users.map(u => ({
id: u.id,
name: { first: u.firstName, last: u.lastName },
email: u.email,
createdAt: u.createdAt.toISOString(),
})),
pagination: { page: 1, perPage: 20, total: users.length },
});
});
app.use("/api/v1", v1Router);
app.use("/api/v2", v2Router);Header Versioning
The version is specified in a request header. URLs stay clean, but the version is invisible in browser address bars and logs.
// ❌ Custom header that clients forget to set
// X-API-Version: 2
// ✅ Accept header with vendor media type
// Accept: application/vnd.myapi.v2+json
function versionMiddleware(req: Request, res: Response, next: NextFunction) {
const accept = req.headers.accept ?? "";
const match = accept.match(/application\/vnd\.myapi\.v(\d+)\+json/);
req.apiVersion = match ? parseInt(match[1], 10) : 1; // Default to v1
next();
}
app.get("/api/users", versionMiddleware, async (req, res) => {
const users = await getUsers();
if (req.apiVersion === 1) {
res.json(users.map(u => ({
id: u.id,
name: u.firstName + " " + u.lastName,
email: u.email,
})));
} else if (req.apiVersion === 2) {
res.json({
data: users.map(u => ({
id: u.id,
name: { first: u.firstName, last: u.lastName },
email: u.email,
createdAt: u.createdAt.toISOString(),
})),
pagination: { page: 1, perPage: 20, total: users.length },
});
}
});Query Parameter Versioning
The version is a query parameter. Simple to implement but easily omitted by clients.
// /api/users?version=2
app.get("/api/users", async (req, res) => {
const version = parseInt(req.query.version as string, 10) || 1;
// Route to version-specific handler
});Avoiding Versions: Additive Changes
The best version is no version. Many changes can be made additively without breaking existing clients.
// ❌ Breaking change — renamed field
// V1: { name: "John Doe" }
// V2: { fullName: "John Doe" } // Every V1 client breaks
// ✅ Additive change — new field alongside old field
// V1: { name: "John Doe" }
// V1.1: { name: "John Doe", fullName: "John Doe" }
// Old clients still read "name", new clients can use "fullName"
interface UserResponseV1 {
id: string;
name: string; // Keep for backward compatibility
email: string;
}
interface UserResponseV1_1 extends UserResponseV1 {
fullName: string; // New field, old clients ignore it
firstName: string; // New field
lastName: string; // New field
}Deprecation Strategy
When a version must eventually go away, give clients a clear timeline and warning mechanism.
// Deprecation middleware — warns clients on old versions
function deprecationMiddleware(version: number, sunsetDate: string) {
return (req: Request, res: Response, next: NextFunction) => {
if (req.apiVersion === version) {
res.setHeader("Deprecation", "true");
res.setHeader("Sunset", sunsetDate);
res.setHeader(
"Link",
'</api/v2/docs>; rel="successor-version"'
);
// Log deprecated version usage for migration tracking
console.log(JSON.stringify({
event: "deprecated_api_call",
version,
path: req.path,
clientId: req.headers["x-client-id"],
sunsetDate,
}));
}
next();
};
}
app.use("/api/v1", deprecationMiddleware(1, "2021-06-01T00:00:00Z"));Version Routing with a Controller Pattern
For larger APIs, a clean controller pattern keeps version-specific logic organized.
// ❌ Version checks scattered throughout handler code
app.get("/api/users/:id", (req, res) => {
if (req.apiVersion === 1) { /* ... */ }
else if (req.apiVersion === 2) { /* ... */ }
else if (req.apiVersion === 3) { /* ... */ }
// Unmaintainable spaghetti
});
// ✅ Version-specific controllers with shared business logic
// controllers/users/v1.ts
export class UsersControllerV1 {
async getUser(req: Request, res: Response) {
const user = await userService.findById(req.params.id);
res.json(this.serialize(user));
}
protected serialize(user: User) {
return { id: user.id, name: `${user.firstName} ${user.lastName}` };
}
}
// controllers/users/v2.ts
export class UsersControllerV2 extends UsersControllerV1 {
protected serialize(user: User) {
return {
id: user.id,
name: { first: user.firstName, last: user.lastName },
createdAt: user.createdAt.toISOString(),
};
}
}
// Registration
const v1Users = new UsersControllerV1();
const v2Users = new UsersControllerV2();
v1Router.get("/users/:id", (req, res) => v1Users.getUser(req, res));
v2Router.get("/users/:id", (req, res) => v2Users.getUser(req, res));Key Takeaways
- URL path versioning is the most explicit — visible in logs, easy to route, simple for clients
- Prefer additive changes over new versions — adding fields is backward-compatible, removing or renaming them isn't
- Set deprecation headers and sunset dates — give clients clear timelines and migration paths
- Use controller inheritance for version-specific logic — keep shared business logic in the base, override serialization per version
- Track deprecated version usage — know which clients still call old versions before sunsetting them
- Support at most two versions simultaneously — maintaining more than two active versions creates unsustainable overhead


