Skip to main content

Express Route Versioning

Introduction

As your Express.js application grows and evolves, you'll likely need to update your API endpoints. However, changing existing endpoints can break client applications that rely on them. API versioning allows you to introduce changes while maintaining backward compatibility with existing clients.

Route versioning is a technique that enables you to manage different versions of your API endpoints simultaneously. This prevents disruptions when you need to make breaking changes to your API, giving clients time to adapt to new versions.

In this guide, we'll explore different strategies for implementing API versioning in Express.js applications and their respective advantages and disadvantages.

Why Version Your API Routes?

Before diving into implementation, let's understand why versioning is essential:

  • Backward Compatibility: Allows existing clients to continue using older API versions
  • Evolution: Enables you to improve and refactor your API without breaking changes
  • Transition Period: Provides time for clients to migrate to newer versions
  • Documentation: Makes it clear which features are available in each version

Versioning Strategies

There are several common approaches to API versioning in Express:

  1. URL Path Versioning
  2. Query Parameter Versioning
  3. Header-Based Versioning
  4. Content Negotiation

Let's examine each strategy in detail.

1. URL Path Versioning

URL path versioning is the most straightforward approach, where the version is included directly in the URL path.

Implementation

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

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

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

app.listen(3000, () => {
console.log('Server running on port 3000');
});

For this to work, you would organize your routes in separate files:

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

router.get('/users', (req, res) => {
res.json({
version: 'v1',
data: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
]
});
});

module.exports = router;

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

router.get('/users', (req, res) => {
res.json({
version: 'v2',
data: [
{ id: 1, name: 'John', email: '[email protected]' },
{ id: 2, name: 'Jane', email: '[email protected]' }
]
});
});

module.exports = router;

Advantages

  • Very explicit and visible
  • Easy to understand and implement
  • Good for third-party developers to discover
  • Works with caching mechanisms

Disadvantages

  • URL changes between versions
  • Not as clean as other approaches
  • Can lead to API path bloat

2. Query Parameter Versioning

This approach uses a query parameter to specify the API version.

Implementation

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

app.get('/api/users', (req, res) => {
const version = req.query.version || '1';

if (version === '1') {
return res.json({
version: 'v1',
data: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
]
});
} else if (version === '2') {
return res.json({
version: 'v2',
data: [
{ id: 1, name: 'John', email: '[email protected]' },
{ id: 2, name: 'Jane', email: '[email protected]' }
]
});
} else {
return res.status(400).json({ error: 'Invalid API version' });
}
});

app.listen(3000, () => {
console.log('Server running on port 3000');
});

Example requests:

GET /api/users            // Default to version 1
GET /api/users?version=1 // Explicitly request version 1
GET /api/users?version=2 // Request version 2

Advantages

  • Keeps the base URL consistent
  • Easy to implement
  • Allows for default versioning

Disadvantages

  • Can interfere with other query parameters
  • Less explicit than URL versioning
  • May be ignored by some caching mechanisms

3. Header-Based Versioning

This strategy uses a custom HTTP header to specify the version.

Implementation

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

app.get('/api/users', (req, res) => {
// Check for the custom header, default to version 1
const version = req.headers['accept-version'] || '1';

if (version === '1') {
return res.json({
version: 'v1',
data: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
]
});
} else if (version === '2') {
return res.json({
version: 'v2',
data: [
{ id: 1, name: 'John', email: '[email protected]' },
{ id: 2, name: 'Jane', email: '[email protected]' }
]
});
} else {
return res.status(400).json({ error: 'Invalid API version' });
}
});

app.listen(3000, () => {
console.log('Server running on port 3000');
});

Example request:

GET /api/users
Headers: Accept-Version: 2

Advantages

  • Cleaner URLs
  • Follows HTTP design principles better
  • Separates versioning from resource identification

Disadvantages

  • Less visible
  • Harder to test directly in a browser
  • Requires custom header support

4. Content Negotiation

This approach uses the standard HTTP Accept header to determine the version.

Implementation

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

app.get('/api/users', (req, res) => {
const acceptHeader = req.headers.accept || '';

// Look for patterns like 'application/vnd.myapi.v2+json'
if (acceptHeader.includes('application/vnd.myapi.v2+json')) {
return res.json({
version: 'v2',
data: [
{ id: 1, name: 'John', email: '[email protected]' },
{ id: 2, name: 'Jane', email: '[email protected]' }
]
});
} else {
// Default to v1
return res.json({
version: 'v1',
data: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
]
});
}
});

app.listen(3000, () => {
console.log('Server running on port 3000');
});

Example request:

GET /api/users
Headers: Accept: application/vnd.myapi.v2+json

Advantages

  • Follows HTTP specification
  • Clean URLs
  • More RESTful approach

Disadvantages

  • Complex to implement
  • Less intuitive for API consumers
  • May require additional documentation

Practical Example: Building a Versioned API

Let's create a more complete example of a versioned API using the URL path approach, which is the most straightforward for beginners.

Project Structure

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

Implementation

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

app.use(express.json());

// Mount version-specific routes
app.use('/api/v1', require('./routes/v1'));
app.use('/api/v2', require('./routes/v2'));

// Handle requests to unversioned API endpoint
app.use('/api', (req, res) => {
res.json({
message: 'Please specify an API version (/api/v1 or /api/v2)',
currentVersions: ['v1', 'v2'],
defaultVersion: 'v1'
});
});

// Handle 404 errors
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
javascript
// routes/v1/index.js
const express = require('express');
const router = express.Router();
const usersRouter = require('./users');

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

router.get('/', (req, res) => {
res.json({
version: 'v1',
endpoints: [
'/api/v1/users'
]
});
});

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

// V1 sample data
const users = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
];

// Get all users
router.get('/', (req, res) => {
res.json({
version: 'v1',
data: users
});
});

// Get user by id
router.get('/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) return res.status(404).json({ error: 'User not found' });

res.json({
version: 'v1',
data: user
});
});

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

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

router.get('/', (req, res) => {
res.json({
version: 'v2',
endpoints: [
'/api/v2/users',
'/api/v2/users/:id',
'/api/v2/users/search' // New in v2
]
});
});

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

// V2 enhanced data
const users = [
{ id: 1, name: 'John', email: '[email protected]', role: 'user' },
{ id: 2, name: 'Jane', email: '[email protected]', role: 'admin' }
];

// Get all users
router.get('/', (req, res) => {
res.json({
version: 'v2',
data: users
});
});

// Get user by id
router.get('/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) return res.status(404).json({ error: 'User not found' });

res.json({
version: 'v2',
data: user
});
});

// New in v2: Search users
router.get('/search', (req, res) => {
const { query } = req.query;
if (!query) return res.status(400).json({ error: 'Search query required' });

const results = users.filter(
user => user.name.includes(query) || user.email.includes(query)
);

res.json({
version: 'v2',
data: results
});
});

module.exports = router;

Testing the Versioned API

You can test the API with curl or a tool like Postman:

V1 Endpoints:

GET /api/v1/users

Response:

json
{
"version": "v1",
"data": [
{ "id": 1, "name": "John" },
{ "id": 2, "name": "Jane" }
]
}

V2 Endpoints:

GET /api/v2/users

Response:

json
{
"version": "v2",
"data": [
{ "id": 1, "name": "John", "email": "[email protected]", "role": "user" },
{ "id": 2, "name": "Jane", "email": "[email protected]", "role": "admin" }
]
}

Best Practices for API Versioning

  1. Be Consistent: Choose one versioning strategy and use it consistently.

  2. Document Version Differences: Clearly document what changes between versions.

  3. Default Version: Always specify a default version for clients that don't specify one.

  4. Limit Active Versions: Don't maintain too many active versions simultaneously. Consider deprecating older versions.

  5. Deprecation Notices: When a version will be phased out, notify users through headers or documentation.

  6. Version Major Changes Only: Not every change requires a new version. Only version breaking changes.

  7. Backwards Compatibility: Try to maintain backward compatibility where possible.

Middleware for Version Routing

You can create a middleware to handle versions more elegantly:

javascript
// versionMiddleware.js
function versionRouter(versions) {
return (req, res, next) => {
const version = req.headers['accept-version'] || '1';

if (!versions[version]) {
return res.status(400).json({
error: 'Unsupported API version',
supported: Object.keys(versions)
});
}

versions[version](req, res, next);
};
}

// Usage
app.get('/api/users', versionRouter({
'1': (req, res) => {
res.json({ version: 'v1', data: [ /* v1 data */ ] });
},
'2': (req, res) => {
res.json({ version: 'v2', data: [ /* v2 data */ ] });
}
}));

Summary

Express route versioning is an essential practice for evolving APIs while maintaining backward compatibility. In this guide, we explored several strategies:

  • URL path versioning: Including version in the URL path (/api/v1/users)
  • Query parameter versioning: Using query parameters (/api/users?version=1)
  • Header-based versioning: Using custom headers (Accept-Version: 1)
  • Content negotiation: Using standard HTTP Accept header (Accept: application/vnd.myapi.v1+json)

Each approach has its advantages and disadvantages, but they all serve the same purpose of allowing your API to evolve over time without breaking existing clients.

When implementing versioning, remember to follow consistent practices, document changes between versions, and provide an upgrade path for your API consumers.

Additional Resources

Exercises

  1. Implement a simple versioned API using URL path versioning with at least two versions of a resource.

  2. Convert an existing Express API to use header-based versioning.

  3. Create a middleware that can switch between different versioning strategies based on configuration.

  4. Implement a version deprecation system that sends warning headers when clients use soon-to-be-deprecated API versions.

  5. Design an API that uses semantic versioning (MAJOR.MINOR.PATCH) for its endpoints, and implement logic to handle compatibility between minor versions.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)