Web Engineering ·

API Versioning Strategies: How to Evolve Your API Without Breaking Clients

Every public API eventually needs to change in ways that break existing clients. This guide covers the four main versioning approaches with TypeScript examples, breaking vs non-breaking change taxonomy, deprecation workflows, and how internal microservice versioning differs from public API versioning.

API Versioning Strategies: How to Evolve Your API Without Breaking Clients

You shipped a public API. Clients are using it. Now you need to rename a field, change a response shape, or remove something that turned out to be a mistake. The easy path is to just change it and send an email. The production path requires a system: versioning strategy, deprecation workflow, and a clear taxonomy of what counts as a breaking change and what does not.

This article covers the four main versioning approaches, when each is appropriate, what breaking vs non-breaking actually means in practice, how to run a deprecation cycle without alienating clients, and why internal microservice APIs deserve a different strategy entirely.

The Four Versioning Approaches

URL Path Versioning

The most common approach: the version is part of the URL path.

// Express router setup
import express from "express";

const app = express();

// v1 router
const v1Router = express.Router();
v1Router.get("/users/:id", getUser_v1);
v1Router.post("/users", createUser_v1);

// v2 router — same paths, different handlers
const v2Router = express.Router();
v2Router.get("/users/:id", getUser_v2);
v2Router.post("/users", createUser_v2);

app.use("/v1", v1Router);
app.use("/v2", v2Router);
// v1: flat user object
function getUser_v1(req: Request, res: Response) {
  const user = await db.users.findById(req.params.id);
  res.json({
    id: user.id,
    name: user.name,           // "Jane Smith"
    email: user.email,
  });
}

// v2: structured name, added profile nesting
function getUser_v2(req: Request, res: Response) {
  const user = await db.users.findById(req.params.id);
  res.json({
    id: user.id,
    name: {
      first: user.firstName,
      last: user.lastName,
    },
    email: user.email,
    profile: {
      avatarUrl: user.avatarUrl,
      timezone: user.timezone,
    },
  });
}

URL path versioning is easy to route at the infrastructure layer, easy to document, easy to test with curl, and easy for clients to understand. The tradeoff is that you end up with multiple handler trees to maintain, and clients who never upgrade stay on old versions indefinitely without any friction forcing migration.

Query Parameter Versioning

The version is a query parameter, defaulting to the latest or a specified stable version.

function versionMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const requestedVersion = req.query.version as string | undefined;
  const defaultVersion = "2024-01-01"; // stable, not "latest"

  req.apiVersion = requestedVersion ?? defaultVersion;
  next();
}

app.use(versionMiddleware);

app.get("/users/:id", async (req: Request, res: Response) => {
  const user = await db.users.findById(req.params.id);

  if (req.apiVersion >= "2024-06-01") {
    return res.json(serializeUser_v2(user));
  }

  return res.json(serializeUser_v1(user));
});

Query parameter versioning keeps URLs clean and makes version negotiation explicit at the call site. The downside is that the version logic bleeds into your handlers, and caching becomes complicated because the URL is identical across versions. CDN caches on URL by default; you need Vary: X-API-Version or cache key overrides to get it right.

Header Versioning

The version travels in a request header, keeping URLs completely stable.

// Middleware to extract and validate version from header
function apiVersionMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const versionHeader = req.headers["x-api-version"] as string | undefined;
  const acceptHeader = req.headers["accept"] as string | undefined;

  // Support both dedicated header and Accept header
  const version =
    versionHeader ??
    parseVersionFromAccept(acceptHeader) ??
    "2024-01-01";

  if (!isSupportedVersion(version)) {
    return res.status(400).json({
      error: "unsupported_version",
      message: `API version ${version} is not supported. Supported versions: ${SUPPORTED_VERSIONS.join(", ")}`,
      supportedVersions: SUPPORTED_VERSIONS,
      sunsetVersions: SUNSET_VERSIONS,
    });
  }

  res.setHeader("X-API-Version", version);
  req.apiVersion = version;
  next();
}

function parseVersionFromAccept(accept?: string): string | undefined {
  if (!accept) return undefined;
  // Accept: application/json;version=2024-06-01
  const match = accept.match(/version=([0-9-]+)/);
  return match?.[1];
}

const SUPPORTED_VERSIONS = ["2024-01-01", "2024-06-01", "2025-01-01"];
const SUNSET_VERSIONS = ["2023-06-01"]; // still served with deprecation warning

Header versioning is cleaner from a URL design perspective but harder to work with: you cannot test it with a browser URL bar, and developers forget to include the header and get confused when they get the wrong response shape. Always echo the active version in a response header so clients can verify what they got.

Content Negotiation

The most HTTP-native approach: version is encoded in the Accept and Content-Type media types.

// Custom media types per version
// Accept: application/vnd.yourapi.v2+json

function contentNegotiationMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const accept = req.headers["accept"] ?? "application/json";

  // Parse: application/vnd.yourapi.v2+json
  const vndMatch = accept.match(
    /application\/vnd\.yourapi\.v(\d+)\+json/
  );

  if (vndMatch) {
    req.apiVersion = parseInt(vndMatch[1], 10);
    res.setHeader("Content-Type", `application/vnd.yourapi.v${req.apiVersion}+json`);
  } else {
    // Fallback for plain application/json
    req.apiVersion = 1;
    res.setHeader("Content-Type", "application/json");
  }

  next();
}

Content negotiation is semantically correct, follows HTTP spec precisely, and enables server-driven negotiation where the server can pick the best representation it supports. In practice, most teams avoid it for public APIs because it is unfamiliar to most API consumers, hard to use from API clients and SDKs without explicit setup, and adds friction to debugging.

Tradeoffs at a Glance

┌─────────────────────┬──────────┬──────────┬──────────┬──────────────────────┐
│ Strategy            │ Caching  │ Dev UX   │ Router   │ Best for             │
│                     │          │          │ complexity│                     │
├─────────────────────┼──────────┼──────────┼──────────┼──────────────────────┤
│ URL path (/v1/)     │ Simple   │ Best     │ Medium   │ Public APIs          │
│ Query param (?v=)   │ Hard     │ Good     │ Low      │ Internal dashboards  │
│ Header (X-Version)  │ Medium   │ OK       │ Low      │ B2B APIs             │
│ Content negotiation │ Medium   │ Poor     │ High     │ Hypermedia / HAL     │
└─────────────────────┴──────────┴──────────┴──────────┴──────────────────────┘

URL path versioning wins for public APIs because of developer experience. Query parameter versioning is fine for internal tools where you control the clients. Header versioning works well for B2B where clients are sophisticated enough to handle it. Content negotiation is theoretically correct and practically painful.

Breaking vs Non-Breaking Changes

Not all changes require a new version. Getting this taxonomy right lets you ship more often without the overhead of a full version increment.

Non-Breaking Changes (ship freely)

// Adding optional fields to responses — safe
// v1 response
{ "id": "123", "email": "jane@example.com" }

// After change — clients ignore unknown fields (if they follow Postel's law)
{ "id": "123", "email": "jane@example.com", "createdAt": "2025-01-15T10:00:00Z" }

// Adding optional request fields — safe if truly optional
interface CreateUserRequest {
  email: string;
  name: string;
  timezone?: string;  // new optional field, old clients omit it, gets a default
}

// Relaxing validation rules — safe
// Before: name must be 2-50 chars
// After:  name must be 2-100 chars (existing clients still pass validation)

// New endpoints entirely — safe
// Existing clients do not call routes they do not know about

// New enum values in responses — CONDITIONALLY safe
// Safe only if clients are documented to handle unknown enum values gracefully
// Treat this as breaking if you cannot verify client handling

Breaking Changes (require a new version)

// Renaming a field
// Before
{ "user_name": "jane" }
// After — clients reading user_name now get undefined
{ "name": "jane" }

// Changing a field's type
// Before
{ "age": "25" }    // string
// After
{ "age": 25 }      // number

// Removing a field
// Before
{ "id": "123", "legacyCode": "ABC" }
// After — anything keyed on legacyCode breaks
{ "id": "123" }

// Changing URL structure
// Before: GET /users/:id/settings
// After:  GET /users/:id/preferences  — old URL 404s

// Adding required request fields
interface CreateOrderRequest {
  productId: string;
  quantity: number;
  warehouseId: string;  // new required field — old clients do not send it
}

// Changing error response shape
// Before: { "error": "not_found" }
// After:  { "code": "NOT_FOUND", "message": "Resource not found" }
// Anything parsing the error field breaks

// Changing authentication scheme
// Before: Bearer token in Authorization header
// After:  API key in X-API-Key header

The practical rule: if a client built to the old spec can still function correctly against the new API without any changes, it is non-breaking. If any client would need to change code to avoid breakage, it is breaking.

Deprecation Workflow

Version sunsetting without a proper deprecation workflow is how you burn trust with integrators. A workable process has four phases.

Phase 1: Announce (6-12 months before sunset). Add deprecation headers to all responses on the old version. Document the sunset date publicly. Send direct communication to active API users.

function addDeprecationHeaders(
  res: Response,
  apiVersion: string
): void {
  const deprecationInfo = DEPRECATION_SCHEDULE[apiVersion];
  if (!deprecationInfo) return;

  // RFC 8594 Sunset header — standard, understood by tooling
  res.setHeader(
    "Sunset",
    deprecationInfo.sunsetDate.toUTCString()
  );

  // Deprecation header — draft RFC, increasingly supported
  res.setHeader(
    "Deprecation",
    deprecationInfo.announcedAt.toUTCString()
  );

  // Link to migration guide
  res.setHeader(
    "Link",
    `<https://api.example.com/docs/migrate/${apiVersion}>; rel="deprecation"`
  );
}

const DEPRECATION_SCHEDULE: Record<string, {
  announcedAt: Date;
  sunsetDate: Date;
}> = {
  "v1": {
    announcedAt: new Date("2025-01-01"),
    sunsetDate: new Date("2025-07-01"),
  },
};

Phase 2: Track (ongoing through the deprecation window). Log which clients are still calling deprecated endpoints. Use this data to prioritize outreach and to make informed sunset decisions. If 80% of traffic is still on v1 two months before sunset, you need to know that.

async function trackDeprecatedVersionUsage(
  req: Request,
  apiVersion: string
): Promise<void> {
  if (!isDeprecated(apiVersion)) return;

  // Increment per API key per day — cheap aggregation, actionable data
  const key = `deprecated_usage:${apiVersion}:${req.apiKey}:${todayDateString()}`;
  await redis.incr(key);
  await redis.expire(key, 90 * 24 * 60 * 60); // 90 days
}

Phase 3: Restrict (30-60 days before sunset). Rate-limit the deprecated version more aggressively. Return 200 responses with a prominent warning field in the body (in addition to headers). Some clients only check response bodies, not headers.

Phase 4: Sunset. Return 410 Gone for the old version with a clear error body pointing to the migration guide. Keep this response in place for 6+ months rather than immediately removing the route, so stragglers get a useful error instead of a connection refused.

function handleSunsetVersion(req: Request, res: Response): void {
  res.status(410).json({
    error: "version_sunset",
    message: "API version v1 was sunset on 2025-07-01.",
    migrationGuide: "https://api.example.com/docs/migrate/v1",
    currentVersion: "v2",
  });
}

Internal Microservice Versioning

Internal APIs between services you control deserve a different approach. The cost model is different: you own both sides of the contract, you can deploy both services, and you can coordinate changes. But the failure modes are also different: a bad deploy can break internal callers immediately, before any human notices.

The right strategy here is consumer-driven contract testing rather than explicit version numbers. Tools like Pact let each consumer service declare what it expects from a provider, and the provider’s test suite verifies all contracts on every build.

// Consumer test (in the orders service)
// Declares what orders-service expects from users-service
import { Pact } from "@pact-foundation/pact";

const provider = new Pact({
  consumer: "orders-service",
  provider: "users-service",
});

describe("users-service contract", () => {
  it("returns user with billing info for order processing", async () => {
    await provider.addInteraction({
      state: "user 123 exists with active billing",
      uponReceiving: "a request for user 123",
      withRequest: {
        method: "GET",
        path: "/users/123",
        headers: { Authorization: like("Bearer token") },
      },
      willRespondWith: {
        status: 200,
        body: {
          id: like("123"),
          email: like("jane@example.com"),
          billing: {
            customerId: like("cus_abc123"),
            status: like("active"),
          },
        },
      },
    });

    // Actual call to the mock provider
    const user = await usersClient.getUser("123");
    expect(user.billing.status).toBe("active");
  });
});

When the users-service team wants to rename billing.customerId to billing.stripeCustomerId, they run the contract tests, see the orders-service contract fails, and coordinate the change. No version number needed, no separate migration window. The contract IS the version.

For cases where contract testing is not practical (high team count, polyglot environments, or you want an explicit compatibility layer), use URL versioning internally but with a much shorter deprecation window: 2-4 weeks instead of 6-12 months, since you can actually reach all the consumers.

Production Considerations

Version in the error response, always. When a client gets an error from your API, include the active version in the error response. Debugging is much faster when you can see which version handled the request.

Do not version your entire API when only one resource changed. Stripe’s approach is instructive: their API versions are date-based and affect the entire API, but internally they track which specific resources and fields changed in each version. This lets them serve version-specific behavior only where it actually differs, rather than forking entire handler trees.

Schema validation as a compatibility gate. Run your response serializers against the schema for the requested version before sending. This catches shape drift early and prevents silent compatibility breaks.

import { z } from "zod";

const UserSchema_v1 = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string(),
});

const UserSchema_v2 = z.object({
  id: z.string(),
  name: z.object({
    first: z.string(),
    last: z.string(),
  }),
  email: z.string(),
  profile: z.object({
    avatarUrl: z.string().nullable(),
    timezone: z.string(),
  }),
});

function serializeUser(user: DbUser, apiVersion: string): unknown {
  const schemas: Record<string, z.ZodType> = {
    "v1": UserSchema_v1,
    "v2": UserSchema_v2,
  };

  const schema = schemas[apiVersion];
  if (!schema) throw new Error(`Unknown API version: ${apiVersion}`);

  const shaped = shapeUserForVersion(user, apiVersion);

  // Validate before sending — catches regressions in shaping logic
  return schema.parse(shaped);
}

Log the version on every request. Include api_version in your structured logs from day one. When debugging a client report of “it broke last Tuesday,” you want to be able to filter logs by version and see exactly what they received.

SDK versioning follows API versioning, with a lag. If you publish client SDKs, the SDK version and API version are separate concerns. The SDK can add features and fix bugs without the API changing. But when you cut a new API version, the SDK needs a new major version that makes the new API the default. Give SDK users the same deprecation window as API users.

Canary your version migrations. Before flipping all traffic from v1 to v2, run a percentage of calls through both versions and compare responses. Differences catch edge cases in your shaping logic before clients hit them.

Closing

API versioning is not a technical problem, it is a coordination problem with technical tools. Pick URL path versioning for public APIs, use contract tests for internal services, and treat the deprecation window as a product commitment you are making to every integrator. The version number is a promise. Breaking it early, or letting deprecated versions linger forever, costs more than the engineering time saved.

Get the taxonomy right, instrument the deprecation headers from the start, and the rest becomes process.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.