Skip to the solution
MediumSystem Design7 min read

How do you design REST API versioning in a Node.js service?

801 views

The problem

Question presented to candidate: "You need to change the shape of a response field in a way existing clients cannot handle. How do you ship that change without breaking them?"

What a strong answer should cover:

  • URI versioning (/v1/users, /v2/users) β€” the most common approach in practice. The version is visible in every request, trivially cacheable and routable, and easy for any client or tool (including a browser address bar) to understand β€” at the cost of "polluting" the URI with something that is arguably metadata about the representation, not the resource itself.
  • Header-based versioning (a custom header like Api-Version: 2, or content negotiation via a versioned media type in Accept, e.g. application/vnd.myapi.v2+json) β€” keeps the URI itself stable across versions, which some argue is more "correct" REST design (the resource identity does not change; its representation does) β€” at the cost of being less visible/discoverable and slightly harder to test by just pasting a URL in a browser.
  • The decision that matters more than the mechanism: what actually constitutes a breaking change worth a new version β€” removing or renaming a field, changing a field's type or meaning β€” versus a purely additive change (a new optional field) that existing clients can safely ignore and does not need a version bump.
  • A deprecation policy is not optional once you have multiple live versions: a Deprecation/Sunset response header (or an equivalent documented policy) telling clients a version will stop being supported, with a real timeline, is what actually lets you retire an old version instead of supporting it forever.
  • Whichever mechanism is chosen, the routing/dispatch logic should be a thin layer, not duplicated business logic per version β€” a version-specific response shape is usually just a transformation applied to one shared underlying implementation, not two independently maintained code paths.
  • There is no single universally "correct" choice between URI and header-based versioning β€” this is a real, debated trade-off in API design, and the strongest answer names the trade-off explicitly rather than asserting one is simply better.

Clarifying questions expected:

  • "Who are the API's actual consumers β€” a public API with unknown third parties, or an internal service with a small, known set of clients?" β€” public APIs lean toward the more visible/discoverable URI approach in practice.
  • "Is there an existing deprecation/sunset policy, or would this be the first versioned endpoint in the service?" β€” decides how much process needs to be built alongside the code.

Code / implementation expected: Yes β€” implementing at least two of the three strategies against a real router and showing they dispatch correctly is the concrete deliverable.

  • api-design
  • versioning
  • rest
  • breaking-changes

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

7 min read Β· 9 sections

Read the code

JavaScriptURI, custom-header, and content-negotiation versioning β€” all three implemented and hit against a real server
import express from "express";
const app = express();

// 1. URI versioning
app.get("/v1/users/:id", (req, res) => res.json({ id: req.params.id, name: "Ada Lovelace" }));
app.get("/v2/users/:id", (req, res) => res.json({ id: req.params.id, firstName: "Ada", lastName: "Lovelace" }));

// 2. Header-based versioning β€” URL stays stable
app.get("/users/:id", (req, res) => {
  const version = req.headers["api-version"] || "1";
  if (version === "2") return res.json({ id: req.params.id, firstName: "Ada", lastName: "Lovelace" });
  res.json({ id: req.params.id, name: "Ada Lovelace" });
});

// 3. Content-negotiation versioning via a versioned media type
app.get("/users2/:id", (req, res) => {
  const match = /application\/vnd\.myapi\.v(\d+)\+json/.exec(req.headers["accept"] || "");
  const version = match ? match[1] : "1";
  res.set("Content-Type", `application/vnd.myapi.v${version}+json`);
  if (version === "2") return res.json({ id: req.params.id, firstName: "Ada", lastName: "Lovelace" });
  res.json({ id: req.params.id, name: "Ada Lovelace" });
});

// Verified against a real running instance:
// GET /v1/users/1                                            -> { id: '1', name: 'Ada Lovelace' }
// GET /v2/users/1                                            -> { id: '1', firstName: 'Ada', lastName: 'Lovelace' }
// GET /users/1  (no header)                                  -> { id: '1', name: 'Ada Lovelace' }
// GET /users/1  Api-Version: 2                                -> { id: '1', firstName: 'Ada', lastName: 'Lovelace' }
// GET /users2/1 Accept: application/vnd.myapi.v2+json         -> { id: '1', firstName: 'Ada', lastName: 'Lovelace' }

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 65 of 152 in Node.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Node.js questions
Up nextMedium

What does the events.once() helper do and how does it bridge EventEmitter and async/await?

Continue