I’ve been thinking about this problem for weeks. Every time I add a new feature to my API, I worry about breaking someone’s application. I watch my phone, half-expecting an angry email from a developer whose integration just stopped working. This fear isn’t imaginary—it’s the reality of maintaining software that other people depend on. Today, I want to share what I’ve learned about building APIs that can evolve without causing chaos.
Why does this matter now? Because we’re building more interconnected systems than ever before. Your API isn’t just a backend for your frontend anymore. It might be powering mobile apps, third-party integrations, IoT devices, or other microservices. Each of these clients has its own development cycle. You can’t force everyone to update at the same time.
Have you ever wondered how large platforms like Stripe or GitHub manage to change their APIs without breaking thousands of applications? They don’t just push breaking changes and hope for the best. They have a system—a way to introduce new features while keeping old ones working.
Let me show you what I’ve built after studying how successful companies handle this challenge.
First, we need to understand the different ways to version an API. The most common approach is putting the version in the URL. You’ve probably seen URLs like /api/v1/users or /api/v2/users. This is straightforward and easy to understand. The version is right there in the address.
// Simple URL-based version detection
app.use('/api/:version', (req, res, next) => {
const version = req.params.version;
if (!['v1', 'v2', 'v3'].includes(version)) {
return res.status(400).json({
error: 'Unsupported version'
});
}
req.apiVersion = version;
next();
});
But what if you don’t want to change URLs? Some teams prefer header-based versioning. The client sends a header like API-Version: 2 or Accept: application/vnd.myapp.v2+json. This keeps URLs clean and semantic. The same endpoint can serve different versions based on what the client requests.
Which approach is better? It depends on your needs. URL versioning is simpler to debug—you can test different versions just by changing the address in your browser. Header versioning keeps your URL structure stable, which some teams prefer for cleaner API design.
Here’s where things get interesting. Once you have version detection working, you need to handle validation differently for each version. This is where Zod becomes incredibly useful.
import { z } from 'zod';
// Version 1 user schema
const v1UserSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().optional()
});
// Version 2 adds phone number validation
const v2UserSchema = v1UserSchema.extend({
phone: z.string().regex(/^\+?[1-9]\d{1,14}$/),
preferences: z.object({
newsletter: z.boolean().default(true)
}).optional()
});
// Version 3 makes phone required and adds two-factor auth
const v3UserSchema = v2UserSchema.extend({
phone: z.string().regex(/^\+?[1-9]\d{1,14}$/),
twoFactorEnabled: z.boolean().default(false)
}).omit({ age: true }); // We removed age in v3
Notice how each version builds on the previous one? Version 2 adds new fields, while version 3 removes an old field (age) and adds new requirements. This is how APIs evolve in the real world. You add features, you deprecate old ones, and sometimes you remove things entirely.
But here’s a question: how do you handle a request that’s missing a required field in a newer version? Do you reject it outright, or provide a helpful error message?
I prefer the latter. When someone sends a v1-style request to a v3 endpoint, I want to tell them exactly what’s missing and how to fix it.
const validateRequest = (schema, data, version) => {
try {
return schema.parse(data);
} catch (error) {
if (error instanceof z.ZodError) {
// Format errors for better developer experience
const errors = error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
version: `Introduced in ${version}`
}));
throw new ValidationError(
`Validation failed for ${version}`,
errors
);
}
throw error;
}
};
Now let’s talk about something crucial: deprecation. When you’re going to remove a feature, you need to give people time to adjust. I’ve found that a good deprecation process has several steps.
First, mark the endpoint or field as deprecated in your documentation. Then, start returning warning headers with API responses. Finally, after a reasonable period (I usually suggest 6-12 months), you can remove the feature.
const deprecationMiddleware = (req, res, next) => {
if (req.apiVersion === 'v1') {
res.setHeader('Deprecation', 'true');
res.setHeader('Sunset', 'Mon, 01 Jan 2024 00:00:00 GMT');
res.setHeader('Link',
'</api/v2/users>; rel="successor-version"'
);
// Log deprecation usage for analytics
deprecationTracker.track({
endpoint: req.path,
version: 'v1',
client: req.headers['user-agent']
});
}
next();
};
Did you notice the Sunset header? This tells clients exactly when the version will stop working. It’s like giving them a calendar invite for the retirement party. The Link header points them to the newer version they should migrate to.
But how do you know if anyone is still using the old version? You need analytics. I add simple tracking to see which versions are being used, by whom, and for what endpoints.
const analyticsMiddleware = (req, res, next) => {
const startTime = Date.now();
// Capture response after it's sent
const originalSend = res.send;
res.send = function(data) {
const duration = Date.now() - startTime;
analytics.record({
timestamp: new Date().toISOString(),
version: req.apiVersion,
endpoint: req.path,
method: req.method,
statusCode: res.statusCode,
duration: duration,
clientId: req.headers['x-client-id']
});
return originalSend.call(this, data);
};
next();
};
This data is gold. It tells you when you can safely retire an old version. If you see that only 2% of traffic is using v1, and that traffic comes from internal testing tools, you can probably deprecate it. If 40% of your revenue comes from v1 clients, you need to be more careful.
Testing is another area where versioning adds complexity. You need to test each version independently, but also test that migrations between versions work correctly.
describe('User API Versioning', () => {
describe('v1', () => {
it('accepts v1 format', async () => {
const response = await request(app)
.post('/api/v1/users')
.send({ name: 'John', email: '[email protected]' });
expect(response.status).toBe(201);
});
});
describe('v2', () => {
it('requires phone in v2', async () => {
const response = await request(app)
.post('/api/v2/users')
.send({ name: 'John', email: '[email protected]' });
// Missing phone - should fail
expect(response.status).toBe(400);
expect(response.body.errors).toContain('phone');
});
});
describe('version migration', () => {
it('v1 data can be upgraded to v2', async () => {
const v1Data = { name: 'John', email: '[email protected]' };
const v2Data = await migrateV1ToV2(v1Data);
expect(v2Data.phone).toBeDefined();
expect(v2Data.preferences.newsletter).toBe(true);
});
});
});
What about documentation? Each version needs its own documentation. I generate OpenAPI specs for each version automatically from the Zod schemas. This ensures the documentation always matches what the code actually accepts.
const generateOpenAPI = (version, schemas, routes) => {
return {
openapi: '3.0.0',
info: {
title: `My API ${version}`,
version: version,
description: `API version ${version} documentation`
},
paths: generatePathsFromRoutes(routes),
components: {
schemas: generateSchemasFromZod(schemas)
}
};
};
The most important lesson I’ve learned? Communication. When you make breaking changes, you need to tell people early and often. Send emails to registered developers. Post in your developer forum. Update your changelog. Make the migration path as clear as possible.
Some teams even build migration tools that automatically update client code. Others provide SDKs that handle version negotiation automatically. The goal is to make the upgrade process painless.
Remember that API versioning isn’t just about technology—it’s about people. You’re building relationships with other developers. When you break their code without warning, you damage that relationship. When you help them migrate smoothly, you build trust.
I’ve made mistakes in this area. I’ve pushed breaking changes too quickly. I’ve assumed everyone reads my blog posts about upcoming changes. I’ve learned that you need multiple communication channels and plenty of lead time.
What’s the biggest mistake I see teams make? Trying to avoid versioning entirely. They think, “We’ll just never make breaking changes.” But requirements change. Business needs evolve. Security vulnerabilities are discovered. You will need to make breaking changes eventually. It’s better to have a system ready.
Start with simple versioning from day one. Even if you only have v1, set up the structure. Add version headers. Document your versioning policy. Your future self will thank you.
The system I’ve shown you today has worked well for my projects. It’s not perfect—no system is—but it provides a solid foundation. It handles the technical aspects while reminding us of the human aspects.
Building APIs is a responsibility. People build businesses on your API. They integrate it into their workflows. They depend on it. Versioning is how we honor that responsibility while continuing to improve our systems.
What versioning challenges have you faced? Have you found solutions I haven’t mentioned here? I’d love to hear about your experiences. Share your thoughts in the comments below—let’s learn from each other. And if you found this helpful, please share it with someone who’s building APIs. We all get better when we share what we learn.
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva