Skip to content
Express.js ex api 4 min read

API Versioning

An API is a contract, and once clients depend on it you can no longer change response shapes, rename fields, or alter status codes without breaking someone. Versioning lets you introduce incompatible changes behind a new version label while the old one keeps serving existing clients. This page compares the three common strategies — URL path, request header, and media-type versioning — and shows how to mount versioned routers in Express and retire old versions gracefully.

What counts as a breaking change

Not every change needs a new version. You can safely add new endpoints, add optional request fields, and add new fields to responses — well-behaved clients ignore data they don’t recognize. You only need a new version when you make a breaking change: removing or renaming a field, changing a field’s type, changing default behavior, tightening validation, or altering the meaning of a status code. Reserve versioning for these, and prefer additive evolution everywhere else.

Versioning strategies compared

There are three mainstream ways to let a client select a version. They differ in where the version lives: in the URL, in a custom header, or in the Accept media type.

StrategyExampleProsCons
URL pathGET /v1/usersObvious, easy to test in a browser/cURL, simple to routeVersion leaks into resource URLs; not “pure” REST
Custom headerGET /users + API-Version: 1Clean URLs; one URL per resourceInvisible in logs/links; harder to test by hand
Media typeGET /users + Accept: application/vnd.myapp.v1+jsonAligns with HTTP content negotiationVerbose; least familiar to clients

In practice URL path versioning is the most widely used because it is the easiest to discover, cache, and debug. The other approaches are stricter about REST’s uniform-resource ideal but cost you ergonomics.

Pick one strategy and apply it consistently across the whole API. Mixing URL and header versioning forces every client to special-case your service.

URL path versioning

Mount a separate router per version and prefix it with the version segment. Each version is an independent module, so v2 can diverge freely from v1 without conditionals scattered through your handlers.

const express = require("express");
const app = express();

const usersV1 = require("./routes/v1/users");
const usersV2 = require("./routes/v2/users");

app.use("/v1/users", usersV1);
app.use("/v2/users", usersV2);

app.listen(3000);

Each router is an ordinary Express Router. v2 here renames name into structured firstName/lastName fields — a breaking change that justifies the new version.

// routes/v2/users.js
const express = require("express");
const router = express.Router();

router.get("/:id", async (req, res) => {
  const user = await db.users.find(req.params.id);
  res.json({
    id: user.id,
    firstName: user.firstName,
    lastName: user.lastName,
  });
});

module.exports = router;

Output: request and response for the two versions.

GET /v1/users/42  ->  { "id": 42, "name": "Ada Lovelace" }
GET /v2/users/42  ->  { "id": 42, "firstName": "Ada", "lastName": "Lovelace" }

For a cleaner top-level setup, group all routers of a version under one parent router:

const v1 = express.Router();
v1.use("/users", require("./routes/v1/users"));
v1.use("/orders", require("./routes/v1/orders"));

app.use("/v1", v1);

Header and media-type versioning

If you want clean URLs, read the version from a header and dispatch to the right router with a small middleware. This keeps a single resource URL while still routing per version.

function versionRouter(versions, fallback) {
  return (req, res, next) => {
    const requested = req.get("API-Version") || fallback;
    const handler = versions[requested];
    if (!handler) {
      return res.status(400).json({ error: `Unsupported API version: ${requested}` });
    }
    return handler(req, res, next);
  };
}

app.use(
  "/users",
  versionRouter({ "1": usersV1, "2": usersV2 }, "2")
);

Media-type versioning is the same idea applied to the Accept header — parse application/vnd.myapp.v2+json and route accordingly. Express 5 changed router internals (notably path-matching), but app.use mounting and req.get work identically, so these patterns are version-stable.

Deprecation strategy

Adding a version is easy; the hard part is removing the old one. Signal deprecation early and give clients a migration window. Use the standardized Deprecation and Sunset response headers (RFC 8594) plus a Link to migration docs so clients can detect it programmatically.

function deprecate(sunsetDate, docsUrl) {
  return (req, res, next) => {
    res.set("Deprecation", "true");
    res.set("Sunset", sunsetDate); // HTTP-date
    res.set("Link", `<${docsUrl}>; rel="deprecation"`);
    next();
  };
}

app.use("/v1", deprecate("Wed, 31 Dec 2025 23:59:59 GMT", "https://docs.myapp.com/migrate-v2"));

Output: response headers a v1 client now receives.

HTTP/1.1 200 OK
Deprecation: true
Sunset: Wed, 31 Dec 2025 23:59:59 GMT
Link: <https://docs.myapp.com/migrate-v2>; rel="deprecation"

After the sunset date, return 410 Gone for the retired version so clients fail loudly with a clear message rather than getting silent wrong behavior.

Best practices

  • Default to URL path versioning unless a strict REST requirement pushes you to headers — it is the easiest to test, cache, and log.
  • Version the whole API, not individual endpoints; a single global version is far simpler to reason about than per-resource versions.
  • Make additive changes (new fields, new endpoints) without bumping the version, and reserve new versions for genuinely breaking changes.
  • Keep each version’s routes in their own module (routes/v1, routes/v2) so versions evolve independently instead of branching inside handlers.
  • Announce removals with Deprecation/Sunset headers and a documented migration path well before you delete anything.
  • Return 400 for unknown versions and 410 Gone for retired ones so clients get explicit, actionable errors.
Last updated June 14, 2026
Was this helpful?