Episode 3 — NodeJS MongoDB Backend Architecture / 3.9 — REST API Development

3.9.b — API Versioning

In one sentence: API versioning lets you evolve your API without breaking existing clients — URL path versioning (/api/v1/) is the most common and straightforward strategy, but header and query parameter approaches each have trade-offs worth understanding.

Navigation: <- 3.9.a — What is a REST API | 3.9.c — Postman for API Testing ->


1. Why Version Your API?

APIs are contracts between your server and every client consuming it. Once clients depend on your response shape, changing it can break their applications.

ScenarioWithout versioningWith versioning
Rename userName to nameAll clients breakOld clients use v1, new clients use v2
Remove a deprecated fieldSilent failuresv1 still returns it, v2 omits it
Change auth mechanismEvery client must update simultaneouslyGradual migration
Restructure response envelopeChaosParallel support

When you need a new version:

  • Removing or renaming fields in the response
  • Changing the type of a field (string to object)
  • Altering authentication or authorization flow
  • Restructuring the URL scheme
  • Breaking changes to request body format

When you do NOT need a new version:

  • Adding new optional fields to responses
  • Adding new endpoints
  • Adding new optional query parameters
  • Bug fixes that correct behavior to match documentation

2. Versioning Strategies

2.1 URL Path Versioning (Most Common)

The version number lives in the URL path itself.

GET /api/v1/users
GET /api/v2/users
GET /api/v1/users/42/posts
GET /api/v2/users/42/posts

Used by: GitHub, Stripe, Twitter/X, Facebook, Google Maps

ProsCons
Immediately visible and obviousURL changes between versions
Easy to route in any frameworkCan lead to large codebase duplication
Simple to test and debugCaching per-version by URL is easy but duplicates cache
Browser-testable (just change the URL)Purists argue version is not part of the resource identity

2.2 Header Versioning

The version is specified in a custom header or the Accept header.

# Custom header approach
GET /api/users
X-API-Version: 1

# Content negotiation approach (vendor media type)
GET /api/users
Accept: application/vnd.myapp.v2+json

Used by: GitHub (also supports this), Azure DevOps

ProsCons
Clean URLs (resource identity stays the same)Not visible in the URL — harder to test in browser
Follows content negotiation principlesHarder to share links that include version info
Version is metadata, not resource identityMore complex routing logic
Easy to forget the header in requests

2.3 Query Parameter Versioning

The version is a query parameter.

GET /api/users?version=1
GET /api/users?version=2
GET /api/users?v=2

Used by: Google Data APIs (some), Netflix (historically)

ProsCons
Easy to add to existing APIsQuery params are optional — what is the default?
Visible in the URLMixes versioning with filtering/pagination params
Easy to testCan complicate caching (vary by query string)
Less conventional than URL path

3. Strategy Comparison

CriteriaURL PathHeaderQuery Param
VisibilityHighLowMedium
Ease of implementationSimpleModerateSimple
Browser testableYesNo (need tools)Yes
Cache-friendlyYes (different URLs)Needs Vary headerNeeds Vary header
REST purityDebatableHigherLower
Industry adoptionMost popularNicheUncommon
RecommendationDefault choiceWhen URL purity mattersAvoid for public APIs

4. Implementing URL Versioning in Express

Project structure

src/
  routes/
    v1/
      users.js
      posts.js
      index.js        # v1 router aggregator
    v2/
      users.js
      posts.js
      index.js        # v2 router aggregator
  app.js

Step 1: Version-specific route files

// src/routes/v1/users.js
const express = require('express');
const router = express.Router();

// v1: returns flat user object
router.get('/', async (req, res) => {
  const users = await User.find().select('name email');
  res.json({
    version: 'v1',
    data: users
  });
});

router.get('/:id', async (req, res) => {
  const user = await User.findById(req.params.id).select('name email');
  if (!user) return res.status(404).json({ error: 'User not found' });
  res.json({ version: 'v1', data: user });
});

module.exports = router;
// src/routes/v2/users.js
const express = require('express');
const router = express.Router();

// v2: returns nested user object with profile, uses pagination
router.get('/', async (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = parseInt(req.query.limit) || 20;
  const skip = (page - 1) * limit;

  const [users, total] = await Promise.all([
    User.find().skip(skip).limit(limit).select('name email profile'),
    User.countDocuments()
  ]);

  res.json({
    version: 'v2',
    data: users.map(u => ({
      id: u._id,
      name: u.name,
      email: u.email,
      profile: {
        avatar: u.profile?.avatar || null,
        bio: u.profile?.bio || null
      }
    })),
    pagination: {
      page,
      limit,
      total,
      pages: Math.ceil(total / limit)
    }
  });
});

router.get('/:id', async (req, res) => {
  const user = await User.findById(req.params.id).select('name email profile');
  if (!user) return res.status(404).json({ error: { code: 'NOT_FOUND', message: 'User not found' } });

  res.json({
    version: 'v2',
    data: {
      id: user._id,
      name: user.name,
      email: user.email,
      profile: {
        avatar: user.profile?.avatar || null,
        bio: user.profile?.bio || null
      }
    }
  });
});

module.exports = router;

Step 2: Version router aggregators

// src/routes/v1/index.js
const express = require('express');
const router = express.Router();

const usersRouter = require('./users');
const postsRouter = require('./posts');

router.use('/users', usersRouter);
router.use('/posts', postsRouter);

module.exports = router;
// src/routes/v2/index.js
const express = require('express');
const router = express.Router();

const usersRouter = require('./users');
const postsRouter = require('./posts');

router.use('/users', usersRouter);
router.use('/posts', postsRouter);

module.exports = router;

Step 3: Mount versions in app.js

// src/app.js
const express = require('express');
const app = express();

const v1Routes = require('./routes/v1');
const v2Routes = require('./routes/v2');

app.use(express.json());

// Mount versioned routes
app.use('/api/v1', v1Routes);
app.use('/api/v2', v2Routes);

// Default: redirect unversioned to latest
app.use('/api/users', (req, res) => {
  res.redirect(307, `/api/v2/users${req.url === '/' ? '' : req.url}`);
});

// Version not found
app.use('/api/v:version', (req, res) => {
  res.status(400).json({
    error: {
      code: 'INVALID_VERSION',
      message: `API version v${req.params.version} does not exist. Available: v1, v2`
    }
  });
});

app.listen(3000, () => console.log('Versioned API running on port 3000'));

5. Implementing Header Versioning in Express

// Middleware that reads version from header
const versionMiddleware = (req, res, next) => {
  // Check custom header first, then Accept header
  const customVersion = req.headers['x-api-version'];
  const acceptHeader = req.headers['accept'] || '';

  // Parse vendor media type: application/vnd.myapp.v2+json
  const vendorMatch = acceptHeader.match(/application\/vnd\.myapp\.v(\d+)\+json/);

  if (customVersion) {
    req.apiVersion = parseInt(customVersion);
  } else if (vendorMatch) {
    req.apiVersion = parseInt(vendorMatch[1]);
  } else {
    req.apiVersion = 2; // default to latest
  }

  next();
};

app.use(versionMiddleware);

// Route that branches based on version
app.get('/api/users', async (req, res) => {
  if (req.apiVersion === 1) {
    const users = await User.find().select('name email');
    return res.json({ data: users });
  }

  // v2 and above
  const users = await User.find().select('name email profile');
  res.json({
    data: users.map(u => ({
      id: u._id,
      name: u.name,
      email: u.email,
      profile: { avatar: u.profile?.avatar, bio: u.profile?.bio }
    }))
  });
});

6. When to Create a New Version vs Extend Existing

Is it a breaking change?
  |
  +--> NO: Add to current version
  |     - New optional fields in response
  |     - New endpoints
  |     - New optional query params
  |     - Bug fixes
  |
  +--> YES: Do you NEED backward compatibility?
        |
        +--> NO: Coordinate with all clients, update in place
        |
        +--> YES: Create a new version
              - Removed/renamed fields
              - Changed field types
              - Restructured response shape
              - Changed auth mechanism

7. Deprecation Strategies

When retiring an old API version, communicate clearly and give clients time to migrate.

Sunset Header (RFC 8594)

// Middleware for deprecated v1 routes
const deprecationWarning = (req, res, next) => {
  res.set('Sunset', 'Sat, 01 Nov 2026 00:00:00 GMT');
  res.set('Deprecation', 'true');
  res.set('Link', '</api/v2/docs>; rel="successor-version"');

  // Optional: add warning in response body
  res.locals.deprecationNotice = {
    warning: 'API v1 is deprecated and will be removed on 2026-11-01',
    migration_guide: 'https://docs.example.com/migration/v1-to-v2'
  };

  next();
};

app.use('/api/v1', deprecationWarning, v1Routes);

Deprecation timeline best practices

PhaseDurationAction
Announce6+ months beforeBlog post, email, changelog, Sunset header
Soft deprecation3-6 monthsLog warnings, add Sunset headers, notify heavy users
Hard deprecation1-3 monthsReturn Warning headers, slow responses slightly
ShutdownD-dayReturn 410 Gone for all v1 endpoints
// Final shutdown: return 410 Gone
app.use('/api/v1', (req, res) => {
  res.status(410).json({
    error: {
      code: 'VERSION_GONE',
      message: 'API v1 has been retired. Please migrate to /api/v2/',
      migration_guide: 'https://docs.example.com/migration/v1-to-v2'
    }
  });
});

8. Code Sharing Between Versions

Avoid duplicating entire route files. Share business logic, only diverge on response shaping.

// src/services/userService.js — shared business logic
class UserService {
  static async findAll(options = {}) {
    const { page = 1, limit = 20, fields } = options;
    const skip = (page - 1) * limit;
    const query = User.find().skip(skip).limit(limit);
    if (fields) query.select(fields);
    const [users, total] = await Promise.all([query, User.countDocuments()]);
    return { users, total, page, limit };
  }

  static async findById(id) {
    return User.findById(id);
  }
}

// v1/users.js — thin route layer
router.get('/', async (req, res) => {
  const { users } = await UserService.findAll({ fields: 'name email' });
  res.json({ data: users }); // v1 shape: flat, no pagination
});

// v2/users.js — thin route layer
router.get('/', async (req, res) => {
  const result = await UserService.findAll({
    page: req.query.page,
    limit: req.query.limit,
    fields: 'name email profile'
  });
  res.json({  // v2 shape: with pagination metadata
    data: result.users,
    pagination: { page: result.page, limit: result.limit, total: result.total }
  });
});

9. Key Takeaways

  1. Version your API whenever you make breaking changes to the contract.
  2. URL path versioning (/api/v1/) is the industry default — use it unless you have a strong reason not to.
  3. Share business logic between versions; only diverge on request parsing and response shaping.
  4. Deprecate gracefully — Sunset headers, migration guides, and long timelines protect your clients.
  5. Do not version for non-breaking changes — adding optional fields or new endpoints needs no new version.

Explain-It Challenge

Explain without notes:

  1. When should you create a new API version vs extend the existing one?
  2. Compare URL path vs header versioning — which would you pick for a public API and why?
  3. How would you structure Express routes to support v1 and v2 without duplicating all business logic?

Navigation: <- 3.9.a — What is a REST API | 3.9.c — Postman for API Testing ->