JSON API Versioning: URL, Header, Media Type & Breaking Changes
Last updated:
JSON API versioning controls how breaking changes to response structure are communicated to clients — the three main strategies are URL path versioning (/v1/users), Accept header versioning (Accept: application/vnd.api+json;version=2), and query parameter versioning (?api_version=2), each with different tradeoffs in discoverability and cache behavior. URL path versioning is the most widely adopted strategy — used by Stripe (/v1/), Twilio (/2010-04-01/), and GitHub (/v3/) — because it is explicit in logs, cache-friendly, and requires no client header configuration; it is the recommended default for public JSON APIs. This guide covers URL, header, and media-type versioning strategies, what constitutes a breaking vs non-breaking JSON change, semantic versioning for APIs, sunset headers for deprecation, and evolving JSON schemas with backward compatibility.
URL Path Versioning: /v1/, /v2/, Date-Based
URL segment versioning is the dominant strategy for public JSON APIs — embed the version identifier directly in the URL path before the resource name. The version is explicit in logs, visible in browser dev tools, bookmark-friendly, and cached correctly by CDNs without any special configuration because different versions have different URLs. Stripe, GitHub, Twilio, and the majority of public REST APIs use this approach.
Integer versioning (/v1/, /v2/) increments the major version with each set of breaking changes. Date-based versioning (Stripe's YYYY-MM-DD pattern) pins each API key to the dated snapshot active at creation — Stripe ships /v1/charges but internally routes requests through a dated compatibility layer. The cons of URL versioning: the same logical resource exists at multiple URLs (/v1/users/123 and /v2/users/123), which violates strict REST resource identity; routing configuration must be duplicated per version; and running multiple live versions in parallel increases maintenance burden.
// Express.js — separate routers per version
const express = require('express')
const app = express()
// Shared service layer — version-agnostic data access
const { getUserById } = require('./services/users')
// v1 router — old shape: single "name" field
const v1Router = express.Router()
v1Router.get('/users/:id', async (req, res) => {
const user = await getUserById(req.params.id)
res.json({ id: user.id, name: user.fullName, email: user.email })
})
// v2 router — new shape: firstName/lastName split + createdAt
const v2Router = express.Router()
v2Router.get('/users/:id', async (req, res) => {
const user = await getUserById(req.params.id)
res.json({
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
createdAt: user.createdAt,
})
})
// Mount version routers — URLs: /v1/users/:id and /v2/users/:id
app.use('/v1', v1Router)
app.use('/v2', v2Router)
// ── Next.js App Router — versioned directories ──────────────────
// app/api/v1/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getUserById } from '@/lib/services/users'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await getUserById(params.id)
// v1 shape — add deprecation headers
const res = NextResponse.json({
id: user.id,
name: `${user.firstName} ${user.lastName}`,
email: user.email,
})
res.headers.set('Sunset', 'Sat, 01 Jan 2028 00:00:00 GMT')
res.headers.set('Deprecation', 'true')
res.headers.set('Link', '</api/v2/users/' + params.id + '>; rel="successor-version"')
return res
}
// app/api/v2/users/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await getUserById(params.id)
// v2 shape — current version, no deprecation headers
return NextResponse.json({
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
createdAt: user.createdAt,
})
}
// ── Stripe date-based versioning pattern ───────────────────────
// Client pins version via header: Stripe-Version: 2024-06-20
// Each API key is pinned to the version active at key creation
// Stripe routes requests through a dated compatibility adapter:
// GET /v1/charges → internal router checks Stripe-Version header
// → applies compatibility transforms for that dated version
// New dated version ships when breaking changes are introduced
// Existing keys remain on old dated version automaticallyThe architectural key: share all business logic and data access in a version-agnostic service layer. Route handlers apply only the version-specific JSON shape transformation. This avoids duplicating database queries while keeping versioned response contracts independent and independently testable.
Header Versioning: Accept and Custom Headers
Header versioning signals the desired API version through an HTTP request header rather than the URL. The URL stays stable (/api/users/123) while the response shape varies by header value. Two common patterns: the standard Accept header with a vendor MIME type (Accept: application/vnd.api+json;version=2), and a custom header like X-API-Version: 2 or Accept-Version: 2. Header versioning is semantically purer from a REST perspective — the same resource URI returns different representations based on client negotiation — but it adds operational complexity.
# ── Accept header versioning ────────────────────────────────────
# Client requests v2 representation via vendor MIME type
GET /api/users/123 HTTP/1.1
Accept: application/vnd.myapi.v2+json
# Server response — must include Vary to prevent cache poisoning
HTTP/1.1 200 OK
Content-Type: application/vnd.myapi.v2+json
Vary: Accept
{"id":"123","firstName":"Ada","lastName":"Lovelace","email":"ada@example.com"}
# ── Custom X-API-Version header ──────────────────────────────────
GET /api/users/123 HTTP/1.1
X-API-Version: 2
HTTP/1.1 200 OK
Content-Type: application/json
Vary: X-API-Version
{"id":"123","firstName":"Ada","lastName":"Lovelace","email":"ada@example.com"}
# ── JSON:API registered MIME type ────────────────────────────────
# application/vnd.api+json is the IANA-registered media type for the
# JSON:API specification (jsonapi.org). Not to be confused with
# custom vendor types like application/vnd.myapi.v2+json.
GET /api/users/123 HTTP/1.1
Accept: application/vnd.api+json
# ── Next.js App Router — header versioning in a single route ────
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getUserById } from '@/lib/services/users'
import { serializeV1, serializeV2 } from '@/lib/serializers/users'
function parseVersion(request: NextRequest): number {
// Try X-API-Version header first
const versionHeader = request.headers.get('x-api-version')
if (versionHeader) return parseInt(versionHeader, 10)
// Fall back to parsing Accept header
const accept = request.headers.get('accept') ?? ''
const match = accept.match(/version=(d+)/)
if (match) return parseInt(match[1], 10)
return 1 // default to v1
}
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const version = parseVersion(request)
const user = await getUserById(params.id)
if (version === 1) {
const res = NextResponse.json(serializeV1(user))
res.headers.set('Vary', 'X-API-Version, Accept')
res.headers.set('Sunset', 'Sat, 01 Jan 2028 00:00:00 GMT')
return res
}
if (version === 2) {
const res = NextResponse.json(serializeV2(user))
res.headers.set('Vary', 'X-API-Version, Accept')
return res
}
// Unsupported version — 406 Not Acceptable
return NextResponse.json(
{ error: 'Unsupported API version', supported: [1, 2] },
{ status: 406, headers: { 'Vary': 'X-API-Version, Accept' } }
)
}Critical cache consideration: without Vary: X-API-Version (or Vary: Accept), a CDN may cache a v1 response and serve it to v2 clients — a silent correctness bug that is difficult to diagnose. Always set Vary to include every request header that influences the response shape. Header versioning also cannot be tested by pasting a URL into a browser — use curl (curl -H 'X-API-Version: 2' https://api.example.com/users/123) or an HTTP client like Postman.
Breaking vs Non-Breaking JSON Schema Changes
The most important design decision in API versioning is distinguishing breaking changes (require a major version bump) from additive changes (safe to ship without a version bump). The open/closed principle applied to JSON schemas: schemas should be open for extension (new optional fields) and closed for modification (existing fields stay stable).
// ── BREAKING changes — always require a major version bump ──────
// 1. Removing a field — clients accessing response.name crash
// v1 response: { "id": "123", "name": "Ada Lovelace", "email": "ada@example.com" }
// v2 response: { "id": "123", "email": "ada@example.com" } ← name removed
// 2. Renaming a field — same as remove + add
// v1: { "userId": "123" } v2: { "id": "123" } ← renamed
// 3. Changing a field type — string to number breaks JSON.parse expectations
// v1: { "id": "123" } // string ID
// v2: { "id": 123 } // number ID — breaks clients doing strict equality
// 4. Changing date format — breaks date parsing
// v1: { "createdAt": 1716163200 } // Unix timestamp (seconds)
// v2: { "createdAt": "2026-05-20T00:00:00Z" } // ISO-8601 — different type
// 5. Making an optional request field required — existing requests fail
// v1 POST /orders body: { "productId": "p1" } ← region optional
// v2 POST /orders body: { "productId": "p1", "region": "us-east-1" } ← required
// 6. Removing enum values — clients may receive an unhandled value
// v1: status can be "pending" | "active" | "legacy"
// v2: status can be "pending" | "active" ← "legacy" removed
// ── NON-BREAKING (additive) changes — safe without version bump ──
// 1. Adding a new optional field — well-behaved clients ignore unknown fields
// v1: { "id": "123", "name": "Ada" }
// v2: { "id": "123", "name": "Ada", "displayName": "Ada L." } ← new optional
// 2. Adding new enum values — IF clients handle unknown values gracefully
// v1: status: "pending" | "active"
// v2: status: "pending" | "active" | "suspended" ← new value added
// Client code must handle: if (status === "active") { ... } else { /* handle unknown */ }
// 3. Relaxing validation — optional field was required
// v1 POST: { "name": "Ada", "phone": "+1555000" } ← phone required
// v2 POST: { "name": "Ada" } ← phone now optional — existing requests still valid
// 4. Adding a new optional request parameter
// v1 GET /users?limit=20
// v2 GET /users?limit=20&includeDeleted=true ← new optional param
// ── Open/closed principle in JSON Schema ────────────────────────
// Correct: additionalProperties defaults to true — ignore unknown fields
{
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" }
}
// No "additionalProperties: false" — clients MUST accept new fields
}
// Wrong: this breaks clients when you add a new field
{
"type": "object",
"properties": { "id": { "type": "string" }, "name": { "type": "string" } },
"additionalProperties": false // ← never use false on API responses
}The robustness principle (Postel's Law) applied to JSON clients: be conservative in what you send, liberal in what you accept. Well-behaved API clients must ignore unknown fields in JSON responses — failing on unknown fields makes every additive change a breaking change for that client. When writing JSON schema validation for request bodies you control, additionalProperties: false is fine (strict validation of incoming data); never apply it to response schemas that clients parse.
Semantic Versioning for JSON APIs
Semantic versioning (SemVer: MAJOR.MINOR.PATCH) maps cleanly onto JSON API change categories. MAJOR = breaking change requiring client migration; MINOR = new backwards-compatible feature (new optional field, new endpoint); PATCH = bug fix with no contract change. In practice, expose only the MAJOR version in the URL (/v1/, /v2/) and communicate MINOR and PATCH changes through an API changelog — full semver URLs like /v1.2.3/users create operational complexity with no benefit to clients.
// ── SemVer applied to JSON API changes ──────────────────────────
// MAJOR bump (1.x.x → 2.0.0) — breaking, clients MUST update
// Examples:
// - Removed field "name" → split into "firstName" + "lastName"
// - Changed "id" type from string to integer
// - Made "region" required in POST /orders
// - Removed endpoint DELETE /v1/legacy-export
// MINOR bump (1.0.x → 1.1.0) — additive, backwards-compatible
// Examples:
// - Added optional "displayName" field to user responses
// - Added new endpoint GET /v1/users/:id/preferences
// - Added new optional query param ?includeMetadata=true
// - Added new enum value "suspended" to status field
// PATCH bump (1.0.0 → 1.0.1) — bug fix, no contract change
// Examples:
// - Fixed: createdAt returned UTC+1 instead of UTC
// - Fixed: nextCursor was null on last page instead of omitted
// - Fixed: error response missing "code" field for 500 errors
// ── API changelog JSON endpoint ─────────────────────────────────
// Serve a machine-readable changelog at /api/changelog.json
// Clients and monitoring tools can poll this for version info
// GET /api/changelog.json
{
"currentVersion": "2",
"versions": [
{
"version": "2",
"semver": "2.0.0",
"released": "2026-05-20",
"status": "current",
"changes": [
{ "type": "breaking", "description": "Split 'name' field into 'firstName' and 'lastName'" },
{ "type": "breaking", "description": "Changed 'id' from string to UUID string format" },
{ "type": "added", "description": "Added 'createdAt' field to user responses" }
]
},
{
"version": "1",
"semver": "1.3.0",
"released": "2024-01-15",
"status": "deprecated",
"sunset": "2028-01-01T00:00:00Z",
"successor": "/v2/",
"changes": []
}
]
}
// ── Version negotiation ──────────────────────────────────────────
// Clients can request the latest supported version:
// GET /api/changelog.json → find highest non-deprecated version
// Or use an explicit "latest" alias (redirect to current major):
// GET /api/latest/users/123 → 301 Redirect → /api/v2/users/123Publishing a /api/changelog.json endpoint makes version information programmatically accessible — monitoring tools, API gateways, and SDK generators can discover the current version and available migrations without scraping documentation HTML. Version negotiation through a changelog JSON is particularly useful for internal microservices where consumers can auto-detect and adapt to the latest supported version.
Deprecation: Sunset Header and Migration Path
A structured deprecation workflow gives clients machine-readable signals and adequate notice to migrate before an API version is retired. The standard HTTP headers for deprecation signaling are Sunset (RFC 8594), Deprecation (IETF draft RFC 8594 companion), and Link with the successor-version relation. These headers should appear on every response from the deprecated version — not just on error responses — so API monitoring tools and client SDKs can detect them automatically.
# ── Deprecation headers on every v1 response ────────────────────
HTTP/1.1 200 OK
Content-Type: application/json
Sunset: Sat, 01 Jan 2028 00:00:00 GMT
Deprecation: true
Link: </v2/users>; rel="successor-version",
<https://docs.example.com/migration/v1-to-v2>; rel="deprecation"
# Sunset — RFC 8594: retirement date (machine-readable deadline)
# Deprecation — IETF draft: signals this endpoint is deprecated
# Link rel="successor-version" — where to migrate to
# Link rel="deprecation" — human-readable migration documentation
# ── After sunset date — return 410 Gone ─────────────────────────
HTTP/1.1 410 Gone
Content-Type: application/json
{
"error": "API version retired",
"message": "v1 was retired on 2028-01-01. Migrate to v2: https://docs.example.com/migration/v1-to-v2",
"successor": "https://api.example.com/v2/users"
}
// ── Express.js — deprecation middleware for v1 ──────────────────
function deprecateV1(req, res, next) {
const sunsetDate = new Date('2028-01-01T00:00:00.000Z')
// After sunset date — return 410 Gone
if (new Date() > sunsetDate) {
return res.status(410).json({
error: 'API version retired',
message: 'v1 was retired on 2028-01-01. Migrate to v2.',
successor: `https://api.example.com/v2${req.path}`,
})
}
// Before sunset — add deprecation headers to every response
res.setHeader('Sunset', 'Sat, 01 Jan 2028 00:00:00 GMT')
res.setHeader('Deprecation', 'true')
res.setHeader('Link', [
`</v2${req.path}>; rel="successor-version"`,
`<https://docs.example.com/migration/v1-to-v2>; rel="deprecation"`,
].join(', '))
next()
}
app.use('/v1', deprecateV1, v1Router)
// ── Logging deprecated endpoint usage ───────────────────────────
// Track which clients still use deprecated v1 to send targeted reminders
function logDeprecatedUsage(req, res, next) {
console.log(JSON.stringify({
event: 'deprecated_api_access',
version: 'v1',
path: req.path,
apiKey: req.headers['x-api-key'],
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString(),
}))
next()
}
app.use('/v1', logDeprecatedUsage, deprecateV1, v1Router)Deprecation timeline best practices: announce the deprecation date at least 6 months before the sunset date (12 months for widely-used public APIs); begin adding Sunset and Deprecation headers immediately after announcement; publish side-by-side migration examples for every breaking change; email all registered API consumers with the timeline; monitor deprecated endpoint traffic and send targeted migration reminders to teams still using it one month and one week before sunset.
Backward-Compatible JSON Schema Evolution
Backward-compatible JSON schema evolution — shipping improvements to your API without breaking existing clients — is the primary mechanism for extending an API within a major version. The core techniques are: additive-only field additions, the expand-and-contract pattern for field migration, oneOf/anyOf for polymorphic response shapes, and field aliasing during transitions.
// ── Additive field addition (safe — no version bump needed) ─────
// Before: GET /v1/users/123
{ "id": "123", "name": "Ada Lovelace", "email": "ada@example.com" }
// After adding optional "displayName" field:
{ "id": "123", "name": "Ada Lovelace", "email": "ada@example.com", "displayName": "Ada L." }
// Existing clients ignore "displayName" — no breaking change
// ── Expand-and-contract for field renaming ──────────────────────
// Goal: rename "name" → "firstName" + "lastName" in v2
// Step 1 (minor release in v1): add new fields alongside old (expand)
{
"id": "123",
"name": "Ada Lovelace", // ← keep old field, mark deprecated in changelog
"firstName": "Ada", // ← add new fields
"lastName": "Lovelace",
"email": "ada@example.com"
}
// Step 2: clients migrate to use firstName/lastName
// Step 3 (v2 major release): remove old "name" field (contract)
{ "id": "123", "firstName": "Ada", "lastName": "Lovelace", "email": "ada@example.com" }
// ── oneOf / anyOf for polymorphic responses ─────────────────────
// When a field can return different shapes, use JSON Schema oneOf
// GET /v1/payment — returns either card or bank_transfer shape
// JSON Schema with oneOf:
{
"type": "object",
"properties": {
"id": { "type": "string" },
"method": {
"oneOf": [
{
"type": "object",
"properties": {
"type": { "const": "card" },
"last4": { "type": "string" },
"brand": { "type": "string" }
},
"required": ["type", "last4", "brand"]
},
{
"type": "object",
"properties": {
"type": { "const": "bank_transfer" },
"bankName": { "type": "string" },
"accountLast4": { "type": "string" }
},
"required": ["type", "bankName", "accountLast4"]
}
]
}
}
}
// Client discriminates on "type" field — safe to add new oneOf variants
// in a minor release without breaking existing clients
// ── Field aliasing during transition ────────────────────────────
// Return both old and new field names during migration window
// Express.js serializer with aliasing:
function serializeUserTransition(user) {
return {
// Old field (deprecated) — keep for transition period
name: `${user.firstName} ${user.lastName}`,
// New fields — clients migrate to these
firstName: user.firstName,
lastName: user.lastName,
// Unchanged fields
id: user.id,
email: user.email,
}
}
// ── Handling unknown enum values in clients ──────────────────────
// JavaScript — defensive enum handling
function getStatusLabel(status) {
const labels = {
pending: 'Pending',
active: 'Active',
suspended: 'Suspended',
}
// Return a fallback for unknown future enum values
return labels[status] ?? `Unknown (${status})`
}
// TypeScript — use string type with known values
type UserStatus = 'pending' | 'active' | 'suspended' | (string & {})
// The (string & {}) intersection allows unknown values without losing type hintsThe expand-and-contract pattern is the most practical technique for evolving JSON schemas without major version bumps. The transition window (time between expanding and contracting) should be at least one full release cycle — long enough for all clients to migrate. Use your API analytics to confirm client adoption of the new field before removing the old one.
Versioning in Next.js App Router and OpenAPI
Next.js App Router supports multiple versioning patterns. URL path versioning with explicit /v1/ and /v2/ path segments is the most straightforward: create versioned route files at app/api/v1/route.ts and app/api/v2/route.ts. Route groups (app/(v1)/api/route.ts) are a file-organization tool but do not change the URL structure — avoid them for API versioning where the version should be visible in the URL. OpenAPI and Zod-to-OpenAPI enable generating version-specific JSON specs served at /api/v1/openapi.json.
// ── File structure for URL path versioning ───────────────────────
// app/
// api/
// v1/
// users/
// [id]/
// route.ts ← GET /api/v1/users/:id
// orders/
// route.ts ← GET /api/v1/orders
// v2/
// users/
// [id]/
// route.ts ← GET /api/v2/users/:id
// orders/
// route.ts ← GET /api/v2/orders
// lib/
// services/
// users.ts ← shared data access
// serializers/
// users.ts ← version-specific JSON transforms
// lib/serializers/users.ts — version-specific JSON shapes
import type { UserRecord } from '@/lib/services/users'
export function serializeUserV1(user: UserRecord) {
return {
id: user.id,
name: `${user.firstName} ${user.lastName}`,
email: user.email,
}
}
export function serializeUserV2(user: UserRecord) {
return {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
createdAt: user.createdAt,
}
}
// ── OpenAPI versioning with Zod and zod-to-openapi ──────────────
// app/api/v1/openapi.json/route.ts — serve version-specific OpenAPI spec
import { NextResponse } from 'next/server'
import { OpenApiGeneratorV3, OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'
import { z } from 'zod'
const registry = new OpenAPIRegistry()
// v1 user schema
const UserV1Schema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
})
registry.registerPath({
method: 'get',
path: '/api/v1/users/{id}',
summary: 'Get user by ID (v1)',
tags: ['Users'],
responses: {
200: {
description: 'User resource',
content: { 'application/json': { schema: UserV1Schema } },
},
},
})
export async function GET() {
const generator = new OpenApiGeneratorV3(registry.definitions)
const spec = generator.generateDocument({
openapi: '3.0.0',
info: { title: 'My API', version: '1.0.0' },
})
return NextResponse.json(spec)
}
// ── AWS API Gateway stage variable versioning ────────────────────
// API Gateway uses "stages" for versioning: prod, v1, v2
// Stage variables let Lambda functions read the active version:
// GET https://abc123.execute-api.us-east-1.amazonaws.com/v1/users
// Stage variable: stageVariables.apiVersion = "v1"
// Lambda handler reads: const version = event.stageVariables?.apiVersion ?? 'v1'For OpenAPI spec versioning, generate separate openapi.json files per major API version and serve them at versioned paths. API documentation tools (Swagger UI, Redoc, Scalar) can be configured to show version tabs. When using an API gateway (AWS API Gateway, Kong, Nginx), configure version routing at the gateway layer so downstream services do not need to handle URL version parsing themselves.
Key Terms
- breaking change
- Any modification to a JSON API that causes existing clients to fail or produce incorrect results. Examples: removing a field from a response, renaming a field, changing a field's data type (e.g., string to number), making an optional request field required, or removing an endpoint. Breaking changes require a major version bump (v1 -> v2) so clients can choose when to migrate.
- additive change
- A backwards-compatible modification to a JSON API that adds new capabilities without altering or removing existing ones. Adding a new optional response field, a new endpoint, or a new optional request parameter are all additive changes. Additive changes do not require a version bump — existing clients continue working unchanged because well-behaved clients ignore unknown JSON fields (the open/closed principle).
- content negotiation
- The HTTP mechanism by which a client and server agree on the representation format for a resource. The client advertises supported formats via the
Acceptrequest header; the server responds with theContent-Typeheader identifying the chosen format. In header-based API versioning, content negotiation is used to select the API version — the client sendsAccept: application/vnd.myapi.v2+jsonand the server responds with the v2 JSON representation. TheVaryresponse header instructs caches to store separate responses perAcceptvalue. - Sunset header
- An HTTP response header defined in RFC 8594 that communicates the date and time after which a resource or API version will no longer be available. Format:
Sunset: <HTTP-date>, for exampleSunset: Sat, 01 Jan 2028 00:00:00 GMT. It is machine-readable — API monitoring tools, client SDK libraries, and API gateways can detect it and alert developers automatically. TheSunsetheader should be included on every response from a deprecated endpoint, paired with theDeprecationheader and aLinkheader pointing to the successor version. - API changelog
- A versioned record of all changes made to an API, organized by version and change type (breaking, additive, bug fix). An API changelog documents what changed, which version introduced the change, and how clients should migrate. Machine-readable changelog JSON endpoints (
/api/changelog.json) allow clients, SDK generators, and monitoring tools to programmatically discover version information without scraping HTML documentation. Stripe publishes one of the most detailed public API changelogs, with every change listed per dated API version. - version pinning
- A versioning pattern where each API consumer is locked to a specific API version at account creation or integration time, and continues using that version until explicitly opting into a newer one. Stripe's date-based versioning implements version pinning: each API key is associated with the Stripe API version active on the key's creation date. New Stripe API versions are released when breaking changes ship; existing API keys remain on their pinned version. This decouples the server's release schedule from client migration timelines, but requires the server to maintain and test all pinned versions concurrently.
FAQ
What is the best strategy for versioning a JSON API?
URL path versioning (/v1/, /v2/) is the best default strategy for most teams. It is the most widely adopted approach — used by Stripe (/v1/), Twilio (/2010-04-01/), and GitHub (/v3/) — because it is explicit in logs, bookmarkable, testable by pasting a URL into a browser or curl, and cached correctly by CDNs without any extra configuration. Header versioning (Accept: application/vnd.api+json;version=2 or a custom X-API-Version header) keeps URLs clean and is semantically purer from a REST perspective, but it requires clients to send a custom header, cannot be tested in a browser, and requires Vary: Accept or Vary: X-API-Version on responses to prevent CDN cache poisoning. Query parameter versioning (?api_version=2) is simple but pollutes URLs and is often not distinguished from functional query parameters. For public JSON APIs consumed by many clients, URL path versioning is the recommended default. Header versioning is a reasonable choice for internal APIs or APIs consumed exclusively by programmatic clients where REST purity matters. Date-based versioning (Stripe YYYY-MM-DD) is a specialized variant that ties each API key to a specific dated snapshot rather than an integer version — it allows shipping breaking changes without forcing all users to migrate simultaneously.
What JSON changes are considered breaking and require a version bump?
Breaking changes to a JSON API response that require a major version bump include: removing a field from a response (clients may crash on undefined access), renaming a field (same effect as remove and add), changing a field's data type (e.g., converting a string ID to a number, or a Unix timestamp to an ISO-8601 string), changing a field's format in a way that breaks parsing (e.g., changing a date from YYYY-MM-DD to MM/DD/YYYY), making a previously optional request field required (existing requests will fail validation), removing or renaming enum values (clients checking against the old value will miss matches), and removing an endpoint. Non-breaking changes that do NOT require a version bump include: adding a new optional field to a response (well-behaved clients ignore unknown fields), adding a new optional request parameter, adding a new endpoint, adding new enum values (if clients use unknown-value handling — handle unknown enums gracefully), and relaxing validation constraints (e.g., making a previously required field optional). The open/closed principle applied to JSON schemas: schemas should be open for extension (new optional fields) and closed for modification (existing fields stay stable). Clients must be coded to ignore unknown fields — this is Postel's Law (robustness principle) applied to JSON consumers.
How does URL path versioning work for JSON APIs?
URL path versioning embeds the version identifier directly in the URL path, before the resource segment: /v1/users, /v2/orders, /v3/products. The version identifier is typically an integer prefixed with 'v' (v1, v2) or a date string (Stripe uses YYYY-MM-DD like /2024-01-15/). Every resource endpoint is duplicated under each version — /v1/users and /v2/users are treated as separate URL namespaces, each with their own route handlers. In Express.js, you create a separate Router instance per version and mount them: app.use('/v1', v1Router); app.use('/v2', v2Router). In Next.js App Router, you create versioned directories: app/api/v1/users/route.ts and app/api/v2/users/route.ts. The critical architectural pattern is to share business logic and data access in a service layer and apply version-specific JSON serialization in each route handler — this avoids duplicating database queries while keeping the versioned response shapes independent. When deprecating v1, add Sunset and Deprecation headers to v1 route responses. When v1 is finally retired, return 410 Gone with a migration guide URL rather than silently failing.
How do I use HTTP headers to version a JSON API?
Two common header versioning approaches exist. The first uses the standard Accept header with a vendor MIME type: the client sends Accept: application/vnd.myapi.v2+json and the server responds with Content-Type: application/vnd.myapi.v2+json plus Vary: Accept. This is the most REST-compliant approach and follows the HTTP content negotiation spec. The second uses a custom header like X-API-Version: 2 or Accept-Version: 2. The server reads this header and dispatches to the appropriate response serializer, responding with Vary: X-API-Version so CDNs cache separate responses per version. In Next.js App Router with header versioning, create a single route handler, read the version from request.headers.get('x-api-version') or parse it from the Accept header, then call the appropriate serializer function. If the requested version is not supported, return 406 Not Acceptable with a JSON error body listing supported versions. Always set the Vary header to include every request header that influences the response — missing Vary causes CDN cache poisoning where a v1-cached response is served to v2 clients.
How does Stripe handle JSON API versioning?
Stripe uses date-based API versioning rather than integer versions. Every Stripe API key is pinned to the API version that was current on the date the API key was created — for example, 2023-10-16. When Stripe ships a breaking change, they release a new dated version (e.g., 2024-06-20) and existing API keys remain pinned to their creation-date version, so existing integrations continue working without any code changes. To upgrade, developers explicitly update the Stripe-Version header in their requests (Stripe-Version: 2024-06-20) or update the pinned version in their Stripe dashboard. Stripe publishes a detailed changelog at stripe.com/docs/upgrades listing every change in every dated version — the changelog is the migration guide. This version-pinning approach allows Stripe to ship breaking changes continuously without forcing all customers to migrate at once. Each customer upgrades on their own schedule. The tradeoff is significant server-side complexity: Stripe must support and test dozens of concurrent dated API versions simultaneously.
How do I deprecate an old JSON API version?
Deprecating a JSON API version requires both machine-readable signals and human communication. For machine-readable signals: add the Sunset header (RFC 8594) to every response from the deprecated version — Sunset: Sat, 01 Jan 2027 00:00:00 GMT announces the retirement date; add the Deprecation header — Deprecation: true signals this endpoint is deprecated; add a Link header pointing to the successor — Link: </v2/users>; rel="successor-version" and Link: <https://docs.example.com/migration>; rel="deprecation" for the migration guide. These headers are machine-readable — API monitoring tools and client SDK libraries can detect them and alert developers automatically. For human communication: publish a migration guide with side-by-side before/after JSON examples for every breaking change; email or notify registered API consumers; update documentation to mark the old version as deprecated; log all requests to deprecated endpoints and send targeted reminders to teams still using them. Timelines: 6 months minimum for public APIs, 12 months recommended. After the sunset date, return 410 Gone with a JSON error body that includes the migration guide URL.
How do I add a new field to a JSON response without breaking existing clients?
Adding a new optional field to a JSON response is a non-breaking (additive) change, provided existing clients are coded to ignore unknown fields — which is the correct behavior per the robustness principle (Postel's Law). In practice: add the new field to the response JSON alongside existing fields; do not remove any existing fields; document the new field in your API changelog as a minor or additive change; update your OpenAPI/JSON Schema spec to include the new field as optional (not required). The "expand and contract" pattern extends this for field migration: when renaming a field (which is breaking), add the new field name alongside the old one in a minor release (expand), give clients one full major-version cycle to migrate to the new name, then remove the old field name in the next major version (contract). For JSON Schema validation of your request bodies, use additionalProperties: true (or omit it, since true is the default) to allow clients to send extra fields without breaking — this is the schema equivalent of ignoring unknown fields on the server side.
How do I implement API versioning in Next.js?
In Next.js App Router, URL path versioning is implemented with versioned route directories: app/api/v1/users/[id]/route.ts and app/api/v2/users/[id]/route.ts. Each file exports a GET (or POST, PUT, etc.) async function. Share all business logic and data access in a service layer at lib/services/users.ts — the route handlers only apply version-specific JSON serialization. For deprecation headers on v1 routes, set them on the NextResponse before returning: res.headers.set('Sunset', 'Sat, 01 Jan 2028 00:00:00 GMT'). For header versioning in a single route file, read the version from request.headers.get('x-api-version') or parse the Accept header with a regex, then dispatch to the appropriate serializer. For OpenAPI documentation, generate version-specific JSON specs using Zod schemas with zod-to-openapi, served at /api/v1/openapi.json and /api/v2/openapi.json. For AWS API Gateway, use stage variables to inject the active version into Lambda handlers: const version = event.stageVariables?.apiVersion ?? 'v1'.
Further reading and primary sources
- RFC 8594 — The Sunset HTTP Header Field — Standard specification for the Sunset header: machine-readable deprecation deadline for HTTP resources
- Stripe API Versioning and Upgrades — Stripe's date-based versioning model and full changelog for every API version
- IETF Deprecation Header Draft — Draft standard for the Deprecation HTTP response header, complementing the Sunset header
- JSON:API Specification — The JSON:API spec defining application/vnd.api+json and resource versioning conventions
- Semantic Versioning 2.0.0 — The SemVer specification: MAJOR.MINOR.PATCH and how to categorize breaking vs additive changes