Skip to content

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.

3 min read
API versioning diagram showing v1 and v2 endpoints running simultaneously

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.

tstypescript
// 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.

tstypescript
// ❌ 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.

tstypescript
// /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.

tstypescript
// ❌ 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.

tstypescript
// 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.

tstypescript
// ❌ 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

  1. URL path versioning is the most explicit — visible in logs, easy to route, simple for clients
  2. Prefer additive changes over new versions — adding fields is backward-compatible, removing or renaming them isn't
  3. Set deprecation headers and sunset dates — give clients clear timelines and migration paths
  4. Use controller inheritance for version-specific logic — keep shared business logic in the base, override serialization per version
  5. Track deprecated version usage — know which clients still call old versions before sunsetting them
  6. Support at most two versions simultaneously — maintaining more than two active versions creates unsustainable overhead
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX