API Versioning Done Right: How to Evolve Your API Without Breaking Your Clients
Version endpoints, not your whole API ā the strategy that keeps clients happy without maintaining three copies of every route
Senior Developer

Every API that survives long enough becomes a versioning problem.
You ship v1. Users build on it. Six months later you need to change a response shape, rename a field, remove an endpoint that was a bad idea, or restructure authentication. You cannot change v1 ā clients are depending on it. So you ship v2. Then v3. And eventually you are maintaining three versions of every endpoint, three versions of every validation rule, and nobody remembers what is different between them.
There is a better way. This guide covers the strategies that work, the ones that do not, and the practical implementation for a Node.js API that needs to evolve without breaking existing clients.
The Four Versioning Strategies
URL path versioning (/v1/users, /v2/users) ā The most common and the most visible. Easy to test in a browser, easy to document, easy to route. The tradeoff is that the version leaks into every URL, clients have to update base URLs to migrate, and it encourages "full version bumps" when often only one endpoint changed.
Header versioning (Accept: application/vnd.yourapp.v2+json) ā Cleaner URLs, version is a first-class part of the HTTP contract. Harder to test in a browser, less common in public APIs, requires more discipline on the documentation side.
Query parameter versioning (/users?version=2) ā Easiest to implement, worst in practice. Cacheable URLs break because ?version=1 and ?version=2 are different cache keys. Easy to accidentally omit. Not recommended.
Date-based versioning (Stripe-Version: 2024-01-01) ā Stripe's approach. Every API change is tied to a date; clients pin to a specific date and get that version of the API indefinitely. Excellent for large public APIs with many clients. Complex to implement correctly.
For most teams, URL path versioning is the right choice ā visible, debuggable, and widely understood. That is what this guide implements.
The Core Principle: Version Endpoints, Not the Whole API
The most common mistake is treating versioning as a global thing ā when you need to change anything, you bump the entire API from v1 to v2. This is expensive (you duplicate everything), confusing (most of v2 is identical to v1), and leads to version sprawl.
The better approach: version only the endpoints that change. Everything else stays at the current version. A client on v1 that never touches the changed endpoint never needs to migrate.
Implementation
Router Structure
src/
āāā routes/
ā āāā v1/
ā ā āāā users.ts
ā ā āāā orders.ts
ā ā āāā products.ts
ā āāā v2/
ā ā āāā users.ts # Only users changed ā orders and products are still v1
ā āāā index.ts # Mounts all versioned routes// src/routes/index.ts
import express from 'express';
import v1UsersRouter from './v1/users';
import v1OrdersRouter from './v1/orders';
import v1ProductsRouter from './v1/products';
import v2UsersRouter from './v2/users';
const router = express.Router();
// V1 routes
router.use('/v1/users', v1UsersRouter);
router.use('/v1/orders', v1OrdersRouter);
router.use('/v1/products', v1ProductsRouter);
// V2 routes ā only users changed
router.use('/v2/users', v2UsersRouter);
// V2 falls back to V1 for unchanged endpoints
// Orders and products haven't changed ā no v2 routes needed
// Clients on /v2/orders just get v1 behavior
router.use('/v2/orders', v1OrdersRouter);
router.use('/v2/products', v1ProductsRouter);
export default router;This pattern means v2 is not a full rewrite ā it is a targeted override. Clients can adopt v2 and get the updated user endpoints while their order and product calls continue working unchanged.
Versioned Transformers (The Clean Pattern)
The underlying business logic ā database queries, validation, auth ā should not be duplicated between versions. Only the input/output shape changes. Use transformers to handle the shape difference:
// src/services/userService.ts ā shared business logic, no versioning here
export async function getUserById(id: string) {
return db.query.users.findFirst({ where: eq(users.id, id) });
}
// src/routes/v1/users.ts
import { getUserById } from '../../services/userService';
router.get('/:id', authenticate, async (req, res) => {
const user = await getUserById(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
// V1 response shape ā name as a single field
res.json({
id: user.id,
email: user.email,
name: user.fullName, // V1 used 'name'
created_at: user.createdAt,
});
});
// src/routes/v2/users.ts
import { getUserById } from '../../services/userService';
router.get('/:id', authenticate, async (req, res) => {
const user = await getUserById(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
// V2 response shape ā split into first/last name, snake_case ā camelCase
res.json({
id: user.id,
email: user.email,
fullName: user.fullName, // V2 renamed to fullName
createdAt: user.createdAt, // V2 uses camelCase
});
});One database query. Two response shapes. No duplicated business logic.
Request Validation Per Version
Validation rules can change between versions. Use a validation factory:
// src/validators/userValidators.ts
import { z } from 'zod';
// V1 ā name is a single string
export const createUserV1Schema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
password: z.string().min(8),
});
// V2 ā split into firstName and lastName, stronger password rules
export const createUserV2Schema = z.object({
email: z.string().email(),
firstName: z.string().min(1).max(50),
lastName: z.string().min(1).max(50),
password: z.string().min(10).regex(/[A-Z]/).regex(/[0-9]/),
});
export type CreateUserV1 = z.infer<typeof createUserV1Schema>;
export type CreateUserV2 = z.infer<typeof createUserV2Schema>;// src/routes/v2/users.ts
import { createUserV2Schema } from '../../validators/userValidators';
router.post('/', async (req, res) => {
const result = createUserV2Schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
const { firstName, lastName, email, password } = result.data;
const fullName = `${firstName} ${lastName}`;
// Normalize to the internal representation before calling shared service
const user = await createUser({ email, fullName, password });
res.status(201).json({ id: user.id, email: user.email, fullName: user.fullName });
});Version in the Response
Always include the API version in your responses so clients can verify what they got:
// src/middleware/versionHeader.ts
export function versionHeader(req, res, next) {
// Extract version from URL (/v1/..., /v2/...)
const match = req.path.match(/^\/(v\d+)\//);
const version = match ? match[1] : 'v1';
res.setHeader('API-Version', version);
res.setHeader('Deprecation-Warning',
version === 'v1' ? 'v1 is deprecated ā migrate to v2 by 2027-01-01' : ''
);
next();
}Deprecation: How to Kill an Old Version Gracefully
Killing a version without warning destroys trust. The right process:
1. Announce the deprecation with a concrete sunset date ā at least 6 months for consumer APIs, 12 months for enterprise. Document exactly what changed and how to migrate.
2. Add deprecation headers to every v1 response:
res.setHeader('Deprecation', 'true');
res.setHeader('Sunset', 'Sat, 01 Jan 2027 00:00:00 GMT');
res.setHeader('Link', '</v2/users>; rel="successor-version"');3. Log v1 usage so you know who still depends on it:
// Middleware on v1 routes
router.use((req, res, next) => {
logger.warn({
path: req.path,
apiKey: req.user?.id,
userAgent: req.headers['user-agent'],
}, 'Deprecated v1 endpoint called');
next();
});4. Email active v1 users ā use your access logs to find API keys still hitting v1 and reach out directly.
5. Return 410 Gone after the sunset date ā not 404. 410 means "this existed and was intentionally removed," which is more helpful for developers debugging old integrations.
What Not to Do
Do not version your entire API every time one endpoint changes. You end up maintaining N full copies of the same code.
Do not use breaking changes without versioning. Renaming a field in a response, changing a type from string to integer, removing a required parameter ā any of these without a version bump will break clients silently.
Do not sunset versions without logging. You will think nobody uses v1 anymore, turn it off, and get an angry email from a customer whose integration has been running quietly for two years.
Do not maintain more than two major versions simultaneously. v1, v2, and v3 at the same time is three codebases. Keep the window at two: one current, one deprecated with a sunset date.
The Version Lifecycle
v1 ships ā v2 ships (v1 deprecated) ā v1 sunset ā only v2 active
ā
6ā12 month deprecation window
Deprecation headers on every v1 response
Active outreach to v1 users
410 Gone after sunset dateDone consistently, this pattern means clients always have time to migrate, you never break integrations without warning, and your codebase never accumulates more than one deprecated version at a time.
Comments (0)
Login to post a comment.