Versioning APIs · learncode.live

Why Version APIs?

APIs evolve over time. Breaking changes (removing fields, changing response format) must not break existing clients.

Client A (v1)          API Server           Client B (v2)
    │                      │                     │
    │── GET /api/v1/users ─→│                     │
    │←── { name, email } ──│                     │
    │                      │                     │
    │                      │                     │── GET /api/v2/users ──→│
    │                      │                     │←── { name, email,     │
    │                      │                     │     phone, avatar } ──│

Versioning Strategies

StrategyHowExampleProsCons
URL PrefixVersion in URL path/api/v1/usersExplicit, easy to routePollutes URLs
HeaderCustom headerAccept: application/vnd.myapp.v2+jsonClean URLsHarder to test in browser
Query ParamVersion in query/api/users?version=2SimpleCaching issues, not RESTful

Most common and explicit approach.

Directory Structure

routes/
├── v1/
│   ├── users.js
│   └── posts.js
├── v2/
│   ├── users.js
│   └── posts.js
└── index.js

Implementation

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

router.get('/', (req, res) => {
  res.json({
    version: 'v1',
    users: [
      { id: 1, name: 'Alice', email: 'alice@example.com' }
    ]
  });
});

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

router.get('/', (req, res) => {
  res.json({
    version: 'v2',
    users: [
      {
        id: 1,
        name: 'Alice',
        email: 'alice@example.com',
        phone: '+1-555-0123',         // New in v2
        avatar: '/avatars/alice.jpg'  // New in v2
      }
    ]
  });
});

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

router.use('/v1/users', require('./v1/users'));
router.use('/v2/users', require('./v2/users'));

// Default to latest version
router.use('/users', require('./v2/users'));

module.exports = router;
// index.js
const express = require('express');
const app = express();

app.use('/api', require('./routes'));

// Clients can call:
// GET /api/v1/users → v1 response
// GET /api/v2/users → v2 response
// GET /api/users    → latest (v2) response

Router Factory Pattern

// helpers/createVersionedRouter.js
function createVersionedRouter(version, routes) {
  const router = express.Router();

  for (const [method, path, handler] of routes) {
    router[method](path, handler);
  }

  // Add version info to responses
  router.use((req, res, next) => {
    const originalJson = res.json.bind(res);
    res.json = (body) => {
      if (body && typeof body === 'object') {
        body.apiVersion = version;
      }
      return originalJson(body);
    };
    next();
  });

  return router;
}

// routes/v1/users.js
module.exports = createVersionedRouter('1.0', [
  ['get', '/', listUsers],
  ['post', '/', createUser],
  ['get', '/:id', getUser]
]);

// routes/v2/users.js - same pattern, different handler
module.exports = createVersionedRouter('2.0', [
  ['get', '/', listUsersV2],
  ['post', '/', createUserV2],
  ['get', '/:id', getUserV2]
]);

Express Router with Version Prefix

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

router.use('/users', require('./v1/users'));
router.use('/posts', require('./v1/posts'));

module.exports = router;

// routes/v2.js
const router = require('express').Router();

router.use('/users', require('./v2/users'));
router.use('/posts', require('./v2/posts'));

module.exports = router;

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

app.use('/api/v1', require('./routes/v1'));
app.use('/api/v2', require('./routes/v2'));

// Redirect /api to latest version
app.use('/api', (req, res) => {
  res.redirect(301, '/api/v2' + req.path);
});

2. Header-Based Versioning

// middleware/versionResolver.js
function versionResolver(req, res, next) {
  const acceptHeader = req.headers['accept'] || '';
  const versionMatch = acceptHeader.match(/application\/vnd\.myapp\.v(\d+)\+json/);

  if (versionMatch) {
    req.apiVersion = parseInt(versionMatch[1]);
  } else {
    req.apiVersion = 1; // Default
  }

  next();
}

// Usage in controllers
app.get('/api/users', versionResolver, (req, res) => {
  if (req.apiVersion >= 2) {
    return res.json(usersV2()); // Enhanced response
  }
  res.json(usersV1()); // Basic response
});

Client Request

# v1
curl -H "Accept: application/vnd.myapp.v1+json" http://localhost:3000/api/users

# v2
curl -H "Accept: application/vnd.myapp.v2+json" http://localhost:3000/api/users

# No header → default v1
curl http://localhost:3000/api/users

3. Query Parameter Versioning

// middleware/queryVersionResolver.js
function queryVersionResolver(req, res, next) {
  req.apiVersion = parseInt(req.query.version) || 1;
  next();
}

app.use('/api', queryVersionResolver);

app.get('/api/users', (req, res) => {
  if (req.apiVersion >= 2) {
    return res.json(usersV2());
  }
  res.json(usersV1());
});
curl "http://localhost:3000/api/users?version=1"
curl "http://localhost:3000/api/users?version=2"

Migrating Between Versions

Adding Fields (Backward Compatible)

// v1 user response
{ "id": 1, "name": "Alice", "email": "alice@test.com" }

// v2 - adds fields, keeps old ones
{ "id": 1, "name": "Alice", "email": "alice@test.com", "phone": "+1-555-0123" }

Removing Fields (Breaking Change)

// Deprecate field in v2, remove in v3
// v1 includes 'password_hash' (bad, but legacy)
// v2 includes it as deprecated
// v3 removes it

router.get('/:id', (req, res) => {
  const user = getUser(req.params.id);

  if (req.apiVersion >= 3) {
    const { password_hash, ...safeUser } = user;
    return res.json({ success: true, data: safeUser });
  }

  if (req.apiVersion >= 2) {
    const { password_hash, ...safeUser } = user;
    res.warning = 'password_hash will be removed in v3';
    return res.json({ success: true, data: safeUser });
  }

  // v1 - full response (including legacy fields)
  res.json({ success: true, data: user });
});

Versioning in the Controller

// controllers/userController.js
class UserController {
  // Shared logic
  async list(req, res, next) {
    try {
      const users = await User.find();
      const version = req.apiVersion || 1;

      // Transform based on version
      const data = users.map(user => this.serialize(user, version));

      res.json({ success: true, count: data.length, data });
    } catch (err) { next(err); }
  }

  serialize(user, version) {
    const base = {
      id: user._id,
      name: user.name,
      email: user.email
    };

    if (version >= 2) {
      base.phone = user.phone;
      base.avatar = user.avatar;
    }

    if (version >= 3) {
      base.role = user.role;
      base.lastLogin = user.lastLogin;
    }

    return base;
  }
}

module.exports = new UserController();

Version Deprecation

Communicate deprecation via response headers:

function deprecationMiddleware(minVersion, sunsetDate) {
  return (req, res, next) => {
    if (req.apiVersion < minVersion) {
      res.set('Warning', `299 - "This API version is deprecated. Upgrade to v${minVersion}+"`);
      res.set('Sunset', sunsetDate);
      res.set('Link', `<${req.baseUrl}>; rel="successor-version"`);
    }
    next();
  };
}

// Deprecate v1, sunset in 3 months
app.use('/api/v1', deprecationMiddleware(2, 'Sat, 13 Sep 2026 00:00:00 GMT'));

Complete Versioned API Setup

const express = require('express');
const app = express();

app.use(express.json());

// v1 routes
const v1Router = express.Router();
v1Router.use('/users', require('./routes/v1/users'));
v1Router.use('/posts', require('./routes/v1/posts'));
app.use('/api/v1', v1Router);

// v2 routes
const v2Router = express.Router();
v2Router.use('/users', require('./routes/v2/users'));
v2Router.use('/posts', require('./routes/v2/posts'));
v2Router.use('/products', require('./routes/v2/products')); // New in v2
app.use('/api/v2', v2Router);

// Default to latest
app.use('/api', v2Router);

// Version info endpoint
app.get('/api/versions', (req, res) => {
  res.json({
    versions: ['v1', 'v2'],
    latest: 'v2',
    deprecated: ['v1'],
    sunset: { v1: '2026-09-13' }
  });
});

app.listen(3000);

Key Takeaways

  • URL prefix versioning (/api/v1/, /api/v2/) is the most common and explicit approach
  • Keep old versions running alongside new ones - never break existing clients
  • Add fields is backward compatible; remove/rename fields is breaking
  • Use deprecation headers (Warning, Sunset, Link) to communicate version lifecycle
  • Organize route files by version (routes/v1/, routes/v2/)
  • Consider a router factory to reduce boilerplate across versions
  • Provide a /api/versions endpoint to list available versions
  • Default unversioned requests to the latest version
Courses