NodeJSAPI Versioning

API Versioning

Once clients depend on your API, you can't freely change it — a renamed field or removed endpoint breaks their code. Versioning lets you evolve the API while keeping old clients working: you ship v2 with the changes and leave v1 running until everyone migrates. This page covers the versioning strategies, which changes actually require a new version, and how to deprecate gracefully.

Breaking vs non-breaking changes

Change

Breaking?

Adding a new endpoint

No

Adding an optional field to a response

No (usually)

Adding an optional request param

No

Removing or renaming a field

Yes

Changing a field's type or meaning

Yes

Making an optional param required

Yes

Changing status codes or error shapes

Yes

Only breaking changes need a new version
Additive changes — new endpoints, new *optional* fields — don't break existing clients, so they ship without a version bump. You only cut a new version for **breaking** changes: removing/renaming fields, changing types, tightening requirements. Treating every tweak as a new version creates needless churn; treating a breaking change as additive breaks clients. Know the difference.
Strategy 1: URL path versioning (most common)

JS
import { Router } from 'express'

const v1 = Router()
v1.get('/users', listUsersV1)

const v2 = Router()
v2.get('/users', listUsersV2)   // new shape, new behavior

app.use('/v1', v1)
app.use('/v2', v2)

// GET /v1/users → old contract
// GET /v2/users → new contract
`/v1/...` is explicit, cache-friendly, and trivial to route
Putting the version in the path (`/v1/users`) is the most widespread approach: it's visible in logs and browsers, easy to route to separate [routers](/nodejs/express-router), and plays well with caches and CDNs (different URL = different cache entry). Purists object that the *resource* hasn't changed, only its representation — but pragmatically, path versioning wins for clarity. Use a major version only (`v1`, `v2`), not `v1.2.3`.
Strategy 2: header / media-type versioning

JS
// Client picks a version via a header:
//   Accept: application/vnd.myapi.v2+json
app.use((req, res, next) => {
  const accept = req.get('Accept') || ''
  req.apiVersion = accept.includes('v2') ? 2 : 1
  next()
})

app.get('/users', (req, res) => {
  return req.apiVersion === 2 ? listUsersV2(req, res) : listUsersV1(req, res)
})
Header versioning is 'purer' but harder to test and cache
Versioning via `Accept` (content negotiation) keeps URLs stable — arguably more RESTful. But it's invisible in the browser address bar, easy to forget in `curl`, trickier for CDNs to cache (they must vary on `Accept`), and harder to debug. Unless you have a strong reason, path versioning is the more practical default. Whatever you pick, apply it **consistently** across the whole API.
Other strategies (use sparingly)

Strategy

Example

Note

Query param

/users?version=2

Easy but pollutes every URL

Custom header

X-API-Version: 2

Non-standard but simple

Subdomain

v2.api.example.com

Heavy; needs DNS/infra work

Deprecation — retire versions gracefully

JS
// Warn callers of v1 that it's going away, with a sunset date:
v1.use((req, res, next) => {
  res.set('Deprecation', 'true')
  res.set('Sunset', 'Wed, 31 Dec 2026 23:59:59 GMT')   // RFC 8594
  res.set('Link', '</v2/docs>; rel="successor-version"')
  next()
})
Never delete a version without notice and a migration window
Pulling `v1` overnight breaks every client still on it. Deprecate responsibly: announce a **sunset date**, send `Deprecation`/`Sunset` headers so tooling can flag it, document the migration path to `v2`, monitor remaining `v1` traffic, and only remove it once usage is near zero. Supporting two versions in parallel for a transition period is the cost of a stable API.
Keep version differences thin

JS
// Share the core logic; let each version own only its formatting:
async function getUsers(query) {
  return db.users.find(query)        // shared business logic
}

const listUsersV1 = async (req, res) =>
  res.json((await getUsers(req.query)).map(toV1Shape))

const listUsersV2 = async (req, res) =>
  res.json({ data: (await getUsers(req.query)).map(toV2Shape) })
Version the representation, not the whole codebase
Don't fork your entire application per version. Keep one set of business logic and let each version differ only in **serialization** — the response shape, field names, envelope. A thin per-version formatting layer (`toV1Shape`/`toV2Shape`) means a bug fix lands once and both versions benefit. Duplicating logic across versions guarantees they drift apart.
Next
Protect your API from abuse and overload: [Rate Limiting](/nodejs/rate-limiting).