Node.js API Versioning: Don't Break Your Users š
Node.js API Versioning: Don't Break Your Users š
Real confession: The first time I pushed an "innocent" API change to production at Acodez, I broke 5,000 mobile apps in 30 seconds. Changed the response format? "Why not!" Renamed a field? "Makes more sense!" Result? Support tickets exploded. Mobile team furious. iOS/Android users can't update instantly like web users. Lesson learned: API changes are a commitment, not a suggestion! š±
When I was building Node.js APIs, I thought "just update the endpoint, it's my API!" Coming from Laravel where I could quickly iterate on internal tools, Node.js APIs serving mobile apps and third-party integrations taught me a brutal lesson: Your API is a contract. Breaking it breaks trust (and apps)!
Let me save you from the angry Slack messages I received!
Why API Versioning Matters šÆ
Here's what happens when you break your API:
// Your "harmless" change
// Before:
app.get('/api/users/:id', (req, res) => {
res.json({
id: user.id,
name: user.name,
created: user.createdAt // Field name
});
});
// After your "improvement":
app.get('/api/users/:id', (req, res) => {
res.json({
id: user.id,
fullName: user.name, // Renamed!
createdAt: user.createdAt // Renamed!
});
});
What breaks in production:
# iOS app (v1.2, released 3 weeks ago)
user.name // undefined! Was expecting "name", got "fullName"
// App crashes!
# Android app (v1.5, released last week)
user.created // undefined! Was expecting "created", got "createdAt"
// App crashes!
# Web dashboard (your own internal tool)
displayName(user.name) // undefined!
// Dashboard broken!
# Third-party integration (customer's code)
if (data.created) { ... } // undefined!
// Customer's entire workflow broken!
# Support tickets: 200+ in first hour
# Rollback deploy at 2 AM
# Write apology email
# Update resume (just in case)
The brutal truth: Users can't update instantly. iOS app store review? 2-7 days. Android? 1-3 days. Enterprise customers? They test for WEEKS!
Coming from Laravel: Internal tools can be updated instantly. Public APIs? They're like tattoos - permanent commitments! š
The Production Disaster That Taught Me š„
My "quick improvement" at Acodez:
// Version 1 - Been running for 6 months
app.get('/api/orders', async (req, res) => {
const orders = await Order.find();
res.json(orders);
});
// "Improvement" - Added pagination!
app.get('/api/orders', async (req, res) => {
const page = req.query.page || 1;
const limit = 20;
const orders = await Order.find()
.skip((page - 1) * limit)
.limit(limit);
res.json({
data: orders, // Changed structure!
page,
total: await Order.count()
});
});
What I broke:
// Every mobile app expecting array:
orders.map(order => ...) // TypeError: orders.map is not a function
// Because now it's orders.data.map()!
// 5,000 apps crashed
// 500+ 1-star reviews in App Store
// "App suddenly stopped working!"
// Mobile team: "What did you DO?!"
The emergency fix (band-aid solution):
// Detect old clients and send old format
app.get('/api/orders', async (req, res) => {
const page = req.query.page;
// If no pagination requested, assume old client
if (!page) {
const orders = await Order.find();
return res.json(orders); // Old format
}
// New format with pagination
const limit = 20;
const orders = await Order.find()
.skip((page - 1) * limit)
.limit(limit);
res.json({
data: orders,
page,
total: await Order.count()
});
});
Better solution: Version your API from day one!
API Versioning Strategy #1: URL Path Versioning š¤ļø
The most common approach (and my favorite):
// v1 - Original API
app.get('/api/v1/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json({
id: user.id,
name: user.name,
created: user.createdAt
});
});
// v2 - Improved API (breaking changes)
app.get('/api/v2/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json({
id: user.id,
fullName: user.name, // Renamed field
email: user.email, // New field
createdAt: user.createdAt, // Renamed field
profile: { // Nested structure!
bio: user.bio,
avatar: user.avatar
}
});
});
// v3 - Even more improvements
app.get('/api/v3/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json({
id: user.id,
fullName: user.name,
email: user.email,
createdAt: user.createdAt,
updatedAt: user.updatedAt, // New!
profile: {
bio: user.bio,
avatar: user.avatar,
socialLinks: user.socialLinks // New!
},
settings: user.settings // New!
});
});
Organized with Express Router:
// routes/v1/users.js
const express = require('express');
const router = express.Router();
router.get('/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json({
id: user.id,
name: user.name,
created: user.createdAt
});
});
module.exports = router;
// routes/v2/users.js
const express = require('express');
const router = express.Router();
router.get('/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json({
id: user.id,
fullName: user.name,
email: user.email,
createdAt: user.createdAt,
profile: {
bio: user.bio,
avatar: user.avatar
}
});
});
module.exports = router;
// app.js
const v1Users = require('./routes/v1/users');
const v2Users = require('./routes/v2/users');
app.use('/api/v1/users', v1Users);
app.use('/api/v2/users', v2Users);
Why I love this approach:
- ā Crystal clear which version you're calling
- ā Easy to maintain separate codebases
- ā Can deprecate old versions gradually
- ā Works great with mobile apps
- ā No confusion in logs/monitoring
A pattern I use in production:
// Shared logic, different responses
const UserService = require('../services/UserService');
// v1 router
router.get('/:id', async (req, res) => {
const user = await UserService.getUser(req.params.id);
// v1 transformer
res.json(transformUserV1(user));
});
// v2 router
router.get('/:id', async (req, res) => {
const user = await UserService.getUser(req.params.id);
// v2 transformer
res.json(transformUserV2(user));
});
// Same data source, different formats!
API Versioning Strategy #2: Header Versioning š
The "sophisticated" approach:
// Middleware to detect API version from header
const apiVersion = (req, res, next) => {
const version = req.headers['api-version'] || req.headers['accept-version'] || '1';
req.apiVersion = parseInt(version);
next();
};
app.use(apiVersion);
// Single endpoint, version-aware responses
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
if (req.apiVersion === 1) {
return res.json({
id: user.id,
name: user.name,
created: user.createdAt
});
}
if (req.apiVersion === 2) {
return res.json({
id: user.id,
fullName: user.name,
email: user.email,
createdAt: user.createdAt,
profile: {
bio: user.bio,
avatar: user.avatar
}
});
}
// Default to latest
res.json({
id: user.id,
fullName: user.name,
email: user.email,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
profile: {
bio: user.bio,
avatar: user.avatar,
socialLinks: user.socialLinks
},
settings: user.settings
});
});
Usage:
# v1 request
curl -H "api-version: 1" https://api.example.com/users/123
# v2 request
curl -H "api-version: 2" https://api.example.com/users/123
# Latest version (no header)
curl https://api.example.com/users/123
Pros:
- ā Clean URLs (no /v1/, /v2/)
- ā RESTful purists love it
Cons:
- ā Hidden version (not in URL)
- ā Harder to test (need to set headers)
- ā Easy to forget setting header
- ā More complex routing logic
My honest take: Looks elegant, but URL versioning is more practical for real-world usage!
API Versioning Strategy #3: Query Parameter Versioning š
The "quickest" approach:
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
const version = req.query.version || '1';
if (version === '1') {
return res.json({
id: user.id,
name: user.name,
created: user.createdAt
});
}
if (version === '2') {
return res.json({
id: user.id,
fullName: user.name,
email: user.email,
createdAt: user.createdAt,
profile: {
bio: user.bio,
avatar: user.avatar
}
});
}
res.status(400).json({ error: 'Invalid version' });
});
Usage:
# v1
GET /api/users/123?version=1
# v2
GET /api/users/123?version=2
Pros:
- ā Easy to implement
- ā Visible in URL
Cons:
- ā Pollutes query params
- ā Confusing with other query params
- ā Not RESTful
My take: Quick hack for internal APIs, but not recommended for public APIs!
The Smart Way: Version Transformers šØ
Don't duplicate business logic! Use transformers:
// services/UserService.js
class UserService {
async getUser(userId) {
// Single source of truth!
return await User.findById(userId);
}
}
// transformers/userTransformers.js
const transformUserV1 = (user) => ({
id: user.id,
name: user.name,
created: user.createdAt
});
const transformUserV2 = (user) => ({
id: user.id,
fullName: user.name,
email: user.email,
createdAt: user.createdAt,
profile: {
bio: user.bio,
avatar: user.avatar
}
});
const transformUserV3 = (user) => ({
id: user.id,
fullName: user.name,
email: user.email,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
profile: {
bio: user.bio,
avatar: user.avatar,
socialLinks: user.socialLinks
},
settings: user.settings
});
module.exports = { transformUserV1, transformUserV2, transformUserV3 };
// routes/v1/users.js
const { transformUserV1 } = require('../../transformers/userTransformers');
const UserService = require('../../services/UserService');
router.get('/:id', async (req, res) => {
const user = await UserService.getUser(req.params.id);
res.json(transformUserV1(user));
});
// routes/v2/users.js
const { transformUserV2 } = require('../../transformers/userTransformers');
const UserService = require('../../services/UserService');
router.get('/:id', async (req, res) => {
const user = await UserService.getUser(req.params.id);
res.json(transformUserV2(user));
});
Why this is brilliant:
- Business logic: ONE place (UserService)
- Version differences: Isolated in transformers
- Easy to test transformers independently
- No code duplication!
When I was building Node.js APIs at Acodez, transformers saved me from maintaining 3 copies of the same business logic!
Deprecation Strategy (The Gentle Goodbye) š
Don't just delete old versions! Give users time to migrate:
// Deprecation middleware
const deprecationWarning = (version, sunsetDate) => {
return (req, res, next) => {
res.set('X-API-Deprecation-Warning',
`API ${version} is deprecated. Please migrate to latest version by ${sunsetDate}`);
res.set('X-API-Sunset', sunsetDate);
res.set('X-API-Latest-Version', 'v3');
next();
};
};
// Apply to v1 routes
app.use('/api/v1', deprecationWarning('v1', '2026-06-01'));
app.use('/api/v1/users', v1Users);
// Eventually return error
app.use('/api/v1', (req, res) => {
res.status(410).json({
error: 'API v1 has been sunset',
message: 'Please upgrade to v3',
migrationGuide: 'https://docs.example.com/migration/v1-to-v3'
});
});
The deprecation timeline I use:
- Month 1: Announce deprecation, add warning headers
- Month 2: Send emails to API consumers
- Month 3: Start logging v1 usage, identify active users
- Month 4: Reach out to active users individually
- Month 5: Final warning emails
- Month 6: Sunset v1, return 410 Gone
Real-world example:
// Log API version usage
app.use((req, res, next) => {
const version = req.path.split('/')[2]; // Extract version from path
logger.info('API Request', {
version,
endpoint: req.path,
userId: req.user?.id,
userAgent: req.get('user-agent')
});
next();
});
// Weekly report: Which users still on v1?
// Contact them proactively!
Breaking vs. Non-Breaking Changes š
Non-breaking changes (safe to add to existing version):
// SAFE: Adding optional fields
// v1 - Before
{ id: 1, name: "John" }
// v1 - After (clients ignore new fields)
{ id: 1, name: "John", email: "[email protected]" }
// SAFE: Adding new endpoints
GET /api/v1/users/:id // Existing
GET /api/v1/users/:id/orders // New! Old clients unaffected
// SAFE: Adding optional query parameters
GET /api/v1/users?page=1 // New param, but optional
Breaking changes (REQUIRE new version):
// BREAKING: Renaming fields
{ id: 1, name: "John" } // v1
{ id: 1, fullName: "John" } // v2 REQUIRED!
// BREAKING: Removing fields
{ id: 1, name: "John", created: "..." } // v1
{ id: 1, name: "John" } // v2 REQUIRED! (removed "created")
// BREAKING: Changing field types
{ id: 1, created: "2026-01-15" } // v1 (string)
{ id: 1, created: 1705334400 } // v2 REQUIRED! (timestamp)
// BREAKING: Changing response structure
[{id: 1}, {id: 2}] // v1 (array)
{ data: [{id: 1}, {id: 2}] } // v2 REQUIRED! (object)
// BREAKING: Changing status codes
404 for not found // v1
204 for not found // v2 REQUIRED! (different behavior)
My rule: When in doubt, create new version! Better safe than sorry!
Version Migration Guide (Help Your Users) š
Document changes clearly:
# Migration Guide: v1 to v2
## Breaking Changes
### 1. User object structure
**v1:**
```json
{
"id": 123,
"name": "John Doe",
"created": "2026-01-15"
}
v2:
{
"id": 123,
"fullName": "John Doe",
"email": "[email protected]",
"createdAt": "2026-01-15T10:30:00Z",
"profile": {
"bio": "...",
"avatar": "..."
}
}
Migration:
nameāfullNamecreatedācreatedAt(now ISO 8601 format)- New:
emailfield - New:
profilenested object
2. Pagination structure
v1: Array response
[{...}, {...}]
v2: Object with metadata
{
"data": [{...}, {...}],
"page": 1,
"total": 100
}
Migration:
// v1
const users = response;
// v2
const users = response.data;
const total = response.total;
**Coming from Laravel:** Laravel's API Resources are like transformers! Same concept, different syntax!
## Your API Versioning Checklist ā
Before changing your API:
- [ ] Version from day one (start with v1)
- [ ] Document breaking vs. non-breaking changes
- [ ] Use transformers (don't duplicate business logic)
- [ ] Add deprecation warnings (headers + docs)
- [ ] Give users 3-6 months to migrate
- [ ] Monitor version usage (who's still on v1?)
- [ ] Write migration guides
- [ ] Test old versions don't break
- [ ] Version your API docs
- [ ] Communicate changes early!
## The Bottom Line š¬
API versioning isn't optional for public APIs - it's survival! One breaking change can crash thousands of apps!
**The essentials:**
1. **Version from day one** (start with /api/v1/)
2. **Never break existing versions** (create v2 instead)
3. **Use transformers** (single source of truth)
4. **Deprecate gracefully** (6-month timeline)
5. **Document everything** (migration guides save lives)
**When I was building Node.js APIs at Acodez**, API versioning was the difference between "smooth releases" and "production disasters". Coming from Laravel where I could quickly change internal tools, public APIs taught me: **Your API is a promise. Breaking it breaks trust!** šÆ
Think of API versioning like **software updates on your phone** - you don't force everyone to update instantly. You support old versions while encouraging migration. Your users will thank you! š±āØ
---
**Building public APIs?** Connect on [LinkedIn](https://www.linkedin.com/in/anuraghkp) - let's share API design war stories!
**Want to see versioned APIs?** Check my [GitHub](https://github.com/kpanuragh) - properly versioned, documented, and maintained!
*P.S. - If you don't have API versioning yet, add it TODAY before you need to make breaking changes. Your future self will thank you!* šāØ