0x55aa
← Back to Blog

Node.js API Versioning: Don't Break Your Users šŸš€

•12 min read

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:

  1. Month 1: Announce deprecation, add warning headers
  2. Month 2: Send emails to API consumers
  3. Month 3: Start logging v1 usage, identify active users
  4. Month 4: Reach out to active users individually
  5. Month 5: Final warning emails
  6. 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 → fullName
  • created → createdAt (now ISO 8601 format)
  • New: email field
  • New: profile nested 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!* šŸš€āœØ