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.
| Scenario | Without versioning | With versioning |
|---|---|---|
Rename userName to name | All clients break | Old clients use v1, new clients use v2 |
| Remove a deprecated field | Silent failures | v1 still returns it, v2 omits it |
| Change auth mechanism | Every client must update simultaneously | Gradual migration |
| Restructure response envelope | Chaos | Parallel 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
| Pros | Cons |
|---|---|
| Immediately visible and obvious | URL changes between versions |
| Easy to route in any framework | Can lead to large codebase duplication |
| Simple to test and debug | Caching 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
| Pros | Cons |
|---|---|
| Clean URLs (resource identity stays the same) | Not visible in the URL — harder to test in browser |
| Follows content negotiation principles | Harder to share links that include version info |
| Version is metadata, not resource identity | More 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)
| Pros | Cons |
|---|---|
| Easy to add to existing APIs | Query params are optional — what is the default? |
| Visible in the URL | Mixes versioning with filtering/pagination params |
| Easy to test | Can complicate caching (vary by query string) |
| Less conventional than URL path |
3. Strategy Comparison
| Criteria | URL Path | Header | Query Param |
|---|---|---|---|
| Visibility | High | Low | Medium |
| Ease of implementation | Simple | Moderate | Simple |
| Browser testable | Yes | No (need tools) | Yes |
| Cache-friendly | Yes (different URLs) | Needs Vary header | Needs Vary header |
| REST purity | Debatable | Higher | Lower |
| Industry adoption | Most popular | Niche | Uncommon |
| Recommendation | Default choice | When URL purity matters | Avoid 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
| Phase | Duration | Action |
|---|---|---|
| Announce | 6+ months before | Blog post, email, changelog, Sunset header |
| Soft deprecation | 3-6 months | Log warnings, add Sunset headers, notify heavy users |
| Hard deprecation | 1-3 months | Return Warning headers, slow responses slightly |
| Shutdown | D-day | Return 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
- Version your API whenever you make breaking changes to the contract.
- URL path versioning (
/api/v1/) is the industry default — use it unless you have a strong reason not to. - Share business logic between versions; only diverge on request parsing and response shaping.
- Deprecate gracefully — Sunset headers, migration guides, and long timelines protect your clients.
- Do not version for non-breaking changes — adding optional fields or new endpoints needs no new version.
Explain-It Challenge
Explain without notes:
- When should you create a new API version vs extend the existing one?
- Compare URL path vs header versioning — which would you pick for a public API and why?
- 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 ->