So you shipped v1. It went out into the world, signed up customers, and now sits behind a real domain with real integrations pointing at it. Someone built a Zapier workflow. Someone else wrote a mobile app that talks to it. A partner has a billing reconciliation script that runs at 3am every night. And now product wants to change the shape of GET /users because the current response is "kind of weird" and "we should fix it before more people depend on it."

You can't fix it. Not without versioning. The moment you have one external consumer who didn't write the code themselves, the API surface stops being yours alone - and any change you make has to either be backward-compatible or live behind a version boundary that lets old callers stay on the old shape until they're ready to move.

That's the entire game. Versioning is the contract that says "the shape you depend on will keep working for some time, and when it has to change, you'll know in advance and have a way out." Everything else - the URL prefixes, the headers, the deprecation timelines - is just plumbing for that one promise.

Let's walk through how to keep that promise in a Node.js service without painting yourself into a corner.

What "Versioning" Actually Means

Before picking a strategy, it helps to be specific about what changes. There are three places an API can shift under your callers, and they don't all need the same treatment.

The first is representation - the shape of the JSON you send and receive. Adding a field is usually fine. Removing one, renaming one, changing a string into an object, or turning a flat list into a paginated envelope all break consumers.

The second is behavior - what an endpoint does with the same inputs. If POST /orders used to auto-create a customer record and now it doesn't, that's a behavior change even if the response shape is identical. Same for changes to validation, defaults, side effects, status codes.

The third is lifecycle - whether the endpoint exists at all, whether it requires auth, whether it's rate-limited, what error codes it can return. Removing an endpoint is the loudest possible change.

You version because at least one of these has to change in a non-additive way. Adding email_verified to a user response doesn't need a new version. Renaming email to email_address does. The skill is recognising which bucket your proposed change falls into before you commit to a strategy.

The Three Places A Version Can Live

Every versioning scheme answers one question: "where does the caller tell us which version they want?" And there are really only three answers, with one hybrid.

Text
URL path:        GET /v2/users/42
URL parameter:   GET /users/42?api_version=2
Request header:  GET /users/42       (Accept: application/vnd.acme.v2+json)
Content type:    POST /users         (Content-Type: application/vnd.acme.v2+json)

URL path versioning is the most common in the wild. Header versioning is the most "purist". Query parameter versioning is what people do when they didn't plan ahead and need an emergency exit. Content negotiation is the academically-correct answer that real APIs occasionally use well and often use badly.

Each has trade-offs that matter more in some teams than others. Pick the one that fits how your callers actually consume the API, not the one that wins on a whiteboard.

Comparison diagram: where the version lives - URL path, query parameter, Accept header, Content-Type header - with the trade-offs of each.

URL Path Versioning - The Default For A Reason

/v1/users, /v2/users. The version sits in the URL, visible to every cache, proxy, log, and curl command in your stack. There's no ambiguity about which version a caller asked for, because you can read it off the request line.

In Express it costs you exactly one extra line:

TypeScript src/server.ts
import express from 'express';
import { v1Router } from './api/v1/index.js';
import { v2Router } from './api/v2/index.js';

const app = express();

app.use(express.json());
app.use('/v1', v1Router);
app.use('/v2', v2Router);

app.listen(3000);

Each version gets its own router subtree. The routers can share controllers, services, and database access - versioning lives at the edge, not in your business logic. Most of your codebase has no idea there even is a v2.

In Fastify the equivalent is just as small:

TypeScript src/server.ts
import Fastify from 'fastify';
import { v1Plugin } from './api/v1/index.js';
import { v2Plugin } from './api/v2/index.js';

const app = Fastify();

await app.register(v1Plugin, { prefix: '/v1' });
await app.register(v2Plugin, { prefix: '/v2' });

await app.listen({ port: 3000 });

What this buys you: every request is self-describing, every version is independently cacheable at the CDN, and your access logs are immediately useful. Searching for /v1/ traffic tells you exactly how many callers are still on the old shape.

What it costs you: URLs change when versions change, which means anyone who hardcoded a v1 URL has to update something to move. For most consumer APIs that's fine. For embedded systems and long-lived integrations it's a real cost, because hardcoded URLs sit in places nobody can easily update.

This is the strategy used by Stripe (historically), Twilio, GitHub, and most public APIs. There's a reason. It's boring, debuggable, and obvious. Boring is good in infrastructure.

Header-Based Versioning - One URL Forever

The opposite philosophy: the URL describes the resource, the header describes the representation. /users/42 is "the user with ID 42" forever; whether you want it shaped like v1 or v2 is a separate concern that lives in the Accept header.

Http
GET /users/42 HTTP/1.1
Accept: application/vnd.acme.v2+json

The middleware to support this is short:

TypeScript src/middleware/version.ts
import type { Request, Response, NextFunction } from 'express';

const ACCEPT_PATTERN = /application\/vnd\.acme\.v(\d+)\+json/;

export function parseVersion(defaultVersion = 1) {
  return (req: Request, res: Response, next: NextFunction) => {
    const accept = req.headers.accept ?? '';
    const match = accept.match(ACCEPT_PATTERN);
    req.apiVersion = match ? Number(match[1]) : defaultVersion;
    next();
  };
}

Then in your handlers you branch on req.apiVersion when the response shape differs. In practice you push that branching down into a serializer layer (we'll get to that) so handlers stay clean.

What this buys you: URLs are stable. Bookmarks, integration code, and webhook configuration never need to change. Resources have one canonical address and the representation is negotiated.

What it costs you: the version is invisible to anything that only looks at the URL. CDN caching gets fiddly - you have to Vary: Accept and your cache keys explode. Debugging is harder because two requests to the same URL can produce different bodies and you'd never know from the access log alone. New developers on your team will absolutely call the endpoint without the header and get whatever default version you picked, and they'll be confused when the shape doesn't match the docs they were reading.

There's also a philosophical argument here that I find mostly unconvincing: that this is the "RESTful" way and URL versioning isn't. Roy Fielding originally argued resources should have stable identifiers. Sure. But most real APIs are RPC-ish under a REST veneer, and pretending otherwise just makes versioning more painful than it needs to be.

Header versioning is the right call for a small set of cases: APIs with very long-lived integrations, APIs where URLs are public infrastructure (think shortlinks, OAuth redirects, well-known endpoints), and APIs where you've decided you'll be backward-compatible-by-default and only break things rarely.

Query Parameter Versioning - The Pragmatic Escape Hatch

GET /users/42?api_version=2. Aesthetically nobody likes it. Practically it's the easiest thing in the world to add to an existing unversioned API when you suddenly realise you need versioning.

TypeScript src/middleware/version-param.ts
export function parseVersionParam(defaultVersion = 1) {
  return (req: Request, res: Response, next: NextFunction) => {
    const raw = req.query.api_version;
    const n = typeof raw === 'string' ? Number(raw) : NaN;
    req.apiVersion = Number.isInteger(n) && n > 0 ? n : defaultVersion;
    next();
  };
}

What this buys you: zero migration cost. Existing callers don't pass the parameter, you default them to v1, you start advertising ?api_version=2 for the new shape. No URL changes, no Accept header gymnastics.

What it costs you: parameters get dropped. Frameworks normalize URLs and strip "unknown" query keys. Caches sometimes ignore them. Developers definitely forget them. And once you have a precedent of "some behavior lives in query params", you'll find yourself in API design arguments forever about what else belongs there.

Use this when the alternative is "we never version anything." It's better than nothing. It's not better than path-based versioning in a greenfield API.

Content Negotiation - The Underrated Hybrid

Accept and Content-Type headers are the standards-blessed way to negotiate representation. URL versioning and header versioning are both kinds of content negotiation in spirit; full RFC-style content negotiation makes it explicit.

Where this earns its keep is on write endpoints. POST /users accepts a JSON body - but which JSON shape? Without content negotiation you'd have a separate URL per version (/v1/users vs /v2/users). With content negotiation you can have one URL and let the client declare the version of the body it's sending:

Http
POST /users HTTP/1.1
Content-Type: application/vnd.acme.v2+json
Accept: application/vnd.acme.v2+json

{ "email_address": "ada@example.com", "display_name": "Ada" }

Server-side, you split the body parser by Content-Type:

TypeScript src/middleware/body-version.ts
const CONTENT_TYPE_PATTERN = /application\/vnd\.acme\.v(\d+)\+json/;

export function parseBodyVersion() {
  return (req: Request, res: Response, next: NextFunction) => {
    const ct = req.headers['content-type'] ?? '';
    const match = ct.match(CONTENT_TYPE_PATTERN);
    req.bodyVersion = match ? Number(match[1]) : 1;
    next();
  };
}

In practice almost nobody runs this purely. Most APIs that use content negotiation pair it with a path prefix as a fallback, because real callers send messy Content-Type headers or none at all.

The Serializer Layer Is Where The Versioning Actually Lives

Here's the move that makes versioning sustainable instead of soul-destroying: keep one canonical internal representation, and put the versioning at the edge - where data leaves and enters.

Internally, your User object has one shape. It's whatever your database and business logic want. Externally, you have a serializer per version that takes the canonical user and produces the v1 shape or the v2 shape on demand.

TypeScript src/serializers/user.ts
type User = {
  id: string;
  emailAddress: string;
  displayName: string;
  createdAt: Date;
  emailVerifiedAt: Date | null;
};

export function serializeUserV1(user: User) {
  return {
    id: user.id,
    email: user.emailAddress,
    name: user.displayName,
    created_at: user.createdAt.toISOString(),
  };
}

export function serializeUserV2(user: User) {
  return {
    id: user.id,
    email_address: user.emailAddress,
    display_name: user.displayName,
    created_at: user.createdAt.toISOString(),
    email_verified: user.emailVerifiedAt !== null,
    email_verified_at: user.emailVerifiedAt?.toISOString() ?? null,
  };
}

Your handler now picks the serializer based on the requested version, but the controller logic itself is version-agnostic:

TypeScript src/api/users.ts
import { Router } from 'express';
import { findUserById } from '../services/users.js';
import { serializeUserV1, serializeUserV2 } from '../serializers/user.js';

export const userRoutes = Router();

userRoutes.get('/:id', async (req, res) => {
  const user = await findUserById(req.params.id);
  if (!user) return res.status(404).json({ error: 'user_not_found' });

  const body =
    req.apiVersion === 2 ? serializeUserV2(user) : serializeUserV1(user);

  res.json(body);
});

The same pattern goes for input - write a parseUserV1 and parseUserV2 that both produce a canonical, validated internal shape before any service code touches it. Your business logic never sees a "v1 user" or a "v2 user"; it sees a User.

This is the single most important architectural choice in API versioning. Versioning is an edge concern. If it leaks into your domain code, every model, query, and side-effect ends up branching on version, and the codebase becomes unmaintainable in about six months.

The Database Stays One Shape

A related rule: the database never has a users_v1 table and a users_v2 table. The database has users. If a column changes meaning between versions, you migrate the database to the new meaning and let the v1 serializer compute the old representation from the new data.

If that's genuinely impossible - if v2 stores something v1 has no equivalent for, and the v1 shape needs a specific computed value you don't have data to derive - that's a sign the change isn't actually a versioning issue, it's a forking issue. Either rethink the change so it's representable in one schema, or accept that v1 will return some kind of sentinel ("unknown", null, an empty array) for that field after the migration.

Versioning the database makes everything 10x harder. Don't do it. Version the interface, not the storage.

Adding Fields Is Free. Removing Fields Is Expensive.

A rule of thumb that prevents most versioning pain: additive changes to a representation are not breaking changes, and don't need a new version.

Adding email_verified to the v1 user response - fine. Old clients that don't know about it will ignore it. New clients that want it can read it. No version bump.

Removing name from the user response - breaking. Even if you think nobody uses it, somebody does, and you'll find out when their integration starts erroring at 2am. Bumped version, or deprecation period first.

Renaming name to display_name - breaking. It's two operations: add display_name and remove name. The first is free, the second isn't.

Changing the type of created_at from a string to a number - breaking. Even if both representations are "correct", consumer code that calls .split('T')[0] on a number throws.

The implication: you can do a lot of evolution without bumping versions. You only need a major version when you're forced into a non-additive change - usually because the old shape was actively wrong, not just imperfect.

Deprecation - The Hardest Part

Versioning isn't really about supporting multiple versions forever. It's about being able to retire old ones cleanly. Two old versions still in active production traffic five years later is the worst outcome, not the goal.

The hard part is the lifecycle. RFC 8594 defines the Sunset header, which is the standards-blessed way to tell a caller "this endpoint will stop responding after this date":

Http
HTTP/1.1 200 OK
Sunset: Wed, 11 Nov 2026 23:59:59 GMT
Deprecation: true
Link: <https://docs.acme.com/api/migrating-to-v2>; rel="deprecation"
Content-Type: application/json

The Deprecation header is in IETF draft and signals "this is still working but you shouldn't depend on it." The Link header with rel="deprecation" points callers at migration docs. Together they're a polite, machine-readable warning.

A working middleware:

TypeScript src/middleware/deprecate.ts
type DeprecateOptions = {
  sunset: Date;
  link: string;
};

export function deprecate(options: DeprecateOptions) {
  return (req: Request, res: Response, next: NextFunction) => {
    res.set('Deprecation', 'true');
    res.set('Sunset', options.sunset.toUTCString());
    res.set('Link', `<${options.link}>; rel="deprecation"`);
    next();
  };
}

Apply it to the entire v1 router once you've announced the deprecation:

TypeScript src/server.ts
app.use(
  '/v1',
  deprecate({
    sunset: new Date('2026-11-11T23:59:59Z'),
    link: 'https://docs.acme.com/api/migrating-to-v2',
  }),
  v1Router,
);

Most clients will ignore these headers. That's fine. The point isn't that every consumer reads them - it's that you've published a machine-readable, dated promise, and the integrators who care will see it and act.

But headers alone are not a deprecation strategy. Pair them with:

The first is a written announcement with a calendar date. Email, changelog, status page, whatever channels your callers actually read. Vague "soon" is not a deprecation; "after 2026-11-11" is.

The second is telemetry. You need to know who's still on v1, ideally per-account, ideally with enough information to email them personally as the date approaches. A simple counter labeled by version and authenticated principal is enough:

TypeScript src/middleware/version-metrics.ts
import { Counter } from 'prom-client';

const apiVersionRequests = new Counter({
  name: 'api_version_requests_total',
  help: 'API requests labeled by version and authenticated principal',
  labelNames: ['version', 'principal'],
});

export function trackVersionUsage() {
  return (req: Request, res: Response, next: NextFunction) => {
    apiVersionRequests.inc({
      version: String(req.apiVersion ?? 1),
      principal: req.auth?.principalId ?? 'anonymous',
    });
    next();
  };
}

This gives you the data to answer the only question that matters during a deprecation: "is anyone still using v1, and who?" Without it you're guessing.

The third is a migration guide. Not a changelog of differences - a guide that shows every endpoint, the v1 request and response, the v2 request and response, and the diff. Integrators don't read changelogs. They search the migration guide for the endpoint they're calling and copy the new shape.

The fourth is a grace period that matches the cost of migration on the caller's side. A consumer SDK migration is a few days of work for a mid-sized team. A platform migration that requires firmware updates on shipped devices is years. Set the sunset date based on the consumers who'll struggle most, not the median.

Timeline of an API version lifecycle: announce, v2 GA, v1 marked deprecated, Sunset header active, sunset date, v1 returns 410 Gone - with v1 traffic tapering to zero.

When The Sunset Date Arrives

The cleanest thing to do is return 410 Gone for the entire v1 surface, with a body that points at the migration guide.

TypeScript src/api/v1/sunset.ts
import { Router } from 'express';

export const v1SunsetRouter = Router();

v1SunsetRouter.use((req, res) => {
  res
    .status(410)
    .set('Link', '<https://docs.acme.com/api/migrating-to-v2>; rel="deprecation"')
    .json({
      error: 'api_version_gone',
      message: 'API v1 was sunset on 2026-11-11. Please migrate to v2.',
      docs: 'https://docs.acme.com/api/migrating-to-v2',
    });
});

410 Gone is specifically for "this used to exist, it doesn't anymore." It's more correct than 404 for sunsetted endpoints, and well-behaved clients distinguish between "you got the URL wrong" and "the URL is intentionally retired."

Don't quietly turn v1 into v2 routing. Don't return 404. Don't return 500 because you removed the code. Return 410 with a body that explains what happened. The integrators who slept on the migration will see the error immediately, and they'll be able to find the docs without reading your code.

Internal Versioning Is Different From External Versioning

If your Node.js service is consumed by a frontend you also own, plus an iOS app you also own, plus three partner integrations you don't own - those have very different versioning needs.

For first-party consumers, you can move them with the API. Deploy a v2 endpoint, update the frontend to use it, retire the v1 endpoint a week later. The "deprecation period" is whatever your release cadence is.

For third-party consumers, you can't. Their release cadence is theirs, and you have to live with whatever they shipped last year. The sunset date for an internally-owned consumer might be a week; for a third-party consumer it might be 12 months.

The temptation is to handle them with the same versioning surface. Don't. It's reasonable to have an internal API that's unversioned (or YOLO-versioned with whatever shape the latest frontend wants) and a public API that's versioned with formal deprecation. Same business logic, different edges. Two routers, two sets of serializers, two release cadences.

TypeScript src/server.ts
app.use('/internal', internalRouter);
app.use('/v1', v1PublicRouter);
app.use('/v2', v2PublicRouter);

This isn't doubling the work. The internal router is usually thin - it leans on the same services and the same database access, but lets you ship UI-driven shape changes without going through a formal version bump. The public router is the one with the lifecycle commitments.

Testing Across Versions

The thing that bites teams during versioning isn't writing the new version. It's keeping the old one correct while everyone's mental energy goes to the new one.

The fix is the same fix as everywhere else: tests. But specifically, contract tests that pin the shape of each version.

TypeScript test/contracts/user.v1.test.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { app } from '../../src/server.js';

describe('GET /v1/users/:id', () => {
  it('returns the v1 user shape', async () => {
    const res = await request(app)
      .get('/v1/users/test-user-1')
      .set('Authorization', 'Bearer test-token');

    expect(res.status).toBe(200);
    expect(res.body).toMatchObject({
      id: expect.any(String),
      email: expect.any(String),
      name: expect.any(String),
      created_at: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
    });

    // v1 must NOT have v2-only fields
    expect(res.body).not.toHaveProperty('email_address');
    expect(res.body).not.toHaveProperty('display_name');
    expect(res.body).not.toHaveProperty('email_verified');
  });
});

The negative assertions matter as much as the positive ones. They catch the classic regression where someone adds a field to the v2 serializer, accidentally adds it to v1 as well, and ships it for a month before a v1 caller notices and pages someone.

For larger surfaces, consider a JSON schema per version and a single test that validates real responses against the schema. The schema becomes both your test fixture and your published documentation.

When NOT To Version

Three cases where adding a version is the wrong move.

The first is when the change is additive. Adding email_verified to the existing v1 response is not a version bump. Don't pre-emptively bump versions to "be safe" - every new major version is a multi-year commitment to maintain and eventually retire.

The second is when nobody is actually depending on the old shape. If you're three weeks into a project with two internal consumers, both of which you own, just change the shape. Versioning has a real cost; pay it when you have to.

The third is when the "change" is actually a bug fix. If created_at was returning the wrong field for a year and you're correcting it, that's a bug. Document it, ship it, deal with the integrations that were depending on the bug as a separate conversation. Bumping a major version for a bug fix teaches consumers that your major versions don't mean anything.

The versioning bar should be: "we're knowingly making a breaking change to a stable, used surface, and there is no compatible path." If you can't write that sentence honestly, don't bump.

A Note On URL-Versioning Granularity

Whole-API versioning (/v1/* and /v2/*) is the simplest mental model and the right default. But for very large APIs with hundreds of endpoints, "you've just bumped 200 endpoints to v2 because we changed one of them" is real friction.

The alternatives, in order of how often they end well:

Per-resource versioning - /users/v2/42, /orders/v1/99. Sometimes worth it for large product surfaces, but it makes the routing layout more complex and the integration code harder to reason about. "Which version is my client on?" becomes a per-call question instead of a per-app one.

Per-endpoint versioning - even more granular, almost always a mistake. Now every endpoint has its own lifecycle and you'll have endpoints from four different versions in production simultaneously.

API-wide versioning with additive evolution between majors - start at v1, evolve additively for years, only bump to v2 when forced. This is the path most successful long-lived APIs end up on. The "version" mostly only changes when something genuinely demands it.

The right answer for a Node.js service of normal size (under a thousand endpoints): start with whole-API versioning at the path. Lean hard on additive evolution. Only consider finer granularity if you've hit a real, named problem with the simple approach.

What To Take Away

Pick one strategy and commit. URL path versioning is the right default for most Node.js services - it's debuggable, cacheable, and obvious. Use header-based versioning if your URLs are public infrastructure that can't change. Use query parameter versioning only when retrofitting an existing unversioned API.

Put the versioning at the edges. Internal models are version-free; serializers and parsers translate between the canonical internal shape and each external version. The database has one shape per resource - never users_v1 and users_v2.

Treat additive changes as non-breaking. Bump versions only when forced. Every major version is a long-term maintenance commitment that ends with a deprecation you have to actually execute.

Plan the deprecation before you ship the new version. A Sunset header, a written announcement with a date, telemetry that tells you who's still on the old version, a migration guide that's actually useful, and a grace period proportional to the cost of migration on the caller's side.

When the sunset date arrives, return 410 Gone cleanly and don't look back. You promised a date. Keeping that promise is what makes the next version's deprecation believable.