Express API Versioning
Introduction
When building REST APIs with Express.js, one critical aspect that's often overlooked by beginners is API versioning. As your API evolves and improves over time, you'll need to make changes to endpoints, data structures, or functionality. However, existing clients might depend on the old behavior. API versioning allows you to introduce changes without breaking existing integrations.
In this tutorial, you'll learn:
- Why API versioning is important
- Different approaches to API versioning in Express
- How to implement each approach with practical examples
- Best practices for maintaining versioned APIs
Why Version Your API?
Imagine you've built an e-commerce API that many clients rely on. Later, you decide to change how product data is structured, perhaps by nesting category information. Without versioning, this change would break all existing applications using your API.
Versioning creates a contract with your API consumers, promising that a specific version will continue to function in a predictable way, even as newer versions introduce changes.
Common Versioning Strategies
There are several approaches to versioning your Express API:
- URL Path Versioning - Including the version in the URL path (e.g.,
/api/v1/products
) - Query Parameter Versioning - Using query strings (e.g.,
/api/products?version=1
) - Header-based Versioning - Using custom HTTP headers
- Content Negotiation - Using Accept headers
Let's explore each approach with practical examples.
1. URL Path Versioning
This is the most straightforward and widely used approach. You include the version number directly in the URL path.
Implementation
First, let's create a basic Express application with versioned routes:
const express = require('express');
const app = express();
// Create routers for different versions
const v1Router = express.Router();
const v2Router = express.Router();
// Version 1 routes
v1Router.get('/users', (req, res) => {
res.json({
version: 'v1',
users: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
]
});
});
// Version 2 routes (with enhanced user data)
v2Router.get('/users', (req, res) => {
res.json({
version: 'v2',
users: [
{ id: 1, name: 'John', role: 'admin', joined: '2022-01-01' },
{ id: 2, name: 'Jane', role: 'user', joined: '2022-02-15' }
]
});
});
// Mount the routers on different version paths
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
app.listen(3000, () => {
console.log('Server running on port 3000');
});
How it works
-
V1 API endpoint:
http://localhost:3000/api/v1/users
Returns basic user data structure. -
V2 API endpoint:
http://localhost:3000/api/v2/users
Returns enhanced user data with additional fields.
Advantages of URL Path Versioning
- Very explicit and easy to understand
- Easy to test in browsers and documentation tools
- Clear separation between different API versions
Disadvantages
- URL doesn't truly represent the resource anymore, but the version of the API
- Requires changing URLs when moving to a new version
2. Query Parameter Versioning
With this approach, the version is specified as a query parameter in the URL.
Implementation
const express = require('express');
const app = express();
app.get('/api/users', (req, res) => {
// Get version from query parameter, default to v1
const version = req.query.version || 'v1';
if (version === 'v1') {
return res.json({
version: 'v1',
users: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
]
});
} else if (version === 'v2') {
return res.json({
version: 'v2',
users: [
{ id: 1, name: 'John', role: 'admin', joined: '2022-01-01' },
{ id: 2, name: 'Jane', role: 'user', joined: '2022-02-15' }
]
});
} else {
return res.status(400).json({ error: 'Invalid API version' });
}
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
How it works
- V1 API endpoint:
http://localhost:3000/api/users
orhttp://localhost:3000/api/users?version=v1
- V2 API endpoint:
http://localhost:3000/api/users?version=v2
Advantages
- URL still points to the same resource
- Easy to default to the latest version when no version is specified
Disadvantages
- Less explicit than URL path versioning
- Might conflict with other query parameters used for filtering
- Harder to route to completely different handler functions
3. Header-based Versioning
This approach uses custom HTTP headers to specify the API version.
Implementation
const express = require('express');
const app = express();
app.get('/api/users', (req, res) => {
// Read custom header, default to v1
const version = req.headers['x-api-version'] || 'v1';
if (version === 'v1') {
return res.json({
version: 'v1',
users: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
]
});
} else if (version === 'v2') {
return res.json({
version: 'v2',
users: [
{ id: 1, name: 'John', role: 'admin', joined: '2022-01-01' },
{ id: 2, name: 'Jane', role: 'user', joined: '2022-02-15' }
]
});
} else {
return res.status(400).json({ error: 'Invalid API version' });
}
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
How to use
To test this with cURL:
# V1 API call
curl http://localhost:3000/api/users
# V2 API call
curl -H "X-API-Version: v2" http://localhost:3000/api/users
Advantages
- Keeps the URL clean and focused on the resource
- Follows HTTP philosophy where headers provide metadata
- Can be combined with middleware for centralized version handling
Disadvantages
- Less visible and harder to test in browsers
- Requires additional documentation for clients to understand
- Not cacheable by some proxies if versioning affects the response
4. Content Negotiation (Accept Header)
This approach uses the HTTP Accept header to specify which version of the resource representation the client wants.
Implementation
const express = require('express');
const app = express();
app.get('/api/users', (req, res) => {
// Parse the Accept header
const acceptHeader = req.headers.accept || 'application/json';
// Check for version in Accept header
if (acceptHeader.includes('application/vnd.myapi.v2+json')) {
return res.json({
version: 'v2',
users: [
{ id: 1, name: 'John', role: 'admin', joined: '2022-01-01' },
{ id: 2, name: 'Jane', role: 'user', joined: '2022-02-15' }
]
});
} else {
// Default to v1
return res.json({
version: 'v1',
users: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
]
});
}
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
How to use
To test this with cURL:
# V1 API call
curl http://localhost:3000/api/users
# V2 API call
curl -H "Accept: application/vnd.myapi.v2+json" http://localhost:3000/api/users
Advantages
- Most RESTful approach as it aligns with content negotiation principles
- URL remains clean and represents just the resource
- Doesn't introduce non-standard headers
Disadvantages
- Complex for API consumers to understand and use
- Harder to test in basic tools
- Can be challenging to implement if you have many versions
Creating a Middleware for Versioning
To simplify versioning logic, you can create middleware to handle the versioning:
const express = require('express');
const app = express();
// Version middleware
function versionMiddleware(req, res, next) {
// Get version from various possible sources
const urlVersion = req.path.match(/^\/v(\d+)/);
const version =
req.query.version ||
req.headers['x-api-version'] ||
(urlVersion && `v${urlVersion[1]}`) ||
'v1';
req.apiVersion = version;
next();
}
app.use(versionMiddleware);
app.get('/api/users', (req, res) => {
if (req.apiVersion === 'v1') {
// V1 response
return res.json({
version: 'v1',
users: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
]
});
} else if (req.apiVersion === 'v2') {
// V2 response
return res.json({
version: 'v2',
users: [
{ id: 1, name: 'John', role: 'admin', joined: '2022-01-01' },
{ id: 2, name: 'Jane', role: 'user', joined: '2022-02-15' }
]
});
} else {
return res.status(400).json({ error: 'Invalid API version' });
}
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
Best Practices for API Versioning
- Start with versioning from day one - Even for v1, explicitly mark it as such to establish patterns
- Use semantic versioning principles - Major version changes for breaking changes
- Document all versions - Ensure each API version is well documented
- Deprecate, don't delete - Inform users when a version will be retired but give transition time
- Test all active versions - Maintain test suites for every supported version
- Consider a sunset policy - Declare how long you'll support older versions
Real-World Example: Advanced URL Path Versioning
Let's build a more complete example of a product API with versioning and controllers:
const express = require('express');
const app = express();
// Sample data
const products = [
{ id: 1, name: 'Laptop', price: 1200, category: 'Electronics' },
{ id: 2, name: 'Chair', price: 300, category: 'Furniture' }
];
// Controllers
const controllers = {
v1: {
getAllProducts: (req, res) => {
// Simple product representation
const simplifiedProducts = products.map(product => ({
id: product.id,
name: product.name,
price: product.price
}));
res.json({ products: simplifiedProducts });
},
getProductById: (req, res) => {
const product = products.find(p => p.id === parseInt(req.params.id));
if (!product) {
return res.status(404).json({ error: 'Product not found' });
}
res.json({
id: product.id,
name: product.name,
price: product.price
});
}
},
v2: {
getAllProducts: (req, res) => {
// Enhanced product representation with category
res.json({ products });
},
getProductById: (req, res) => {
const product = products.find(p => p.id === parseInt(req.params.id));
if (!product) {
return res.status(404).json({ error: 'Product not found' });
}
res.json(product);
},
searchProducts: (req, res) => {
// New feature in v2: search by name
const { query } = req.query;
if (!query) {
return res.status(400).json({ error: 'Search query required' });
}
const results = products.filter(p =>
p.name.toLowerCase().includes(query.toLowerCase())
);
res.json({ results });
}
}
};
// Version 1 routes
const v1Router = express.Router();
v1Router.get('/products', controllers.v1.getAllProducts);
v1Router.get('/products/:id', controllers.v1.getProductById);
// Version 2 routes
const v2Router = express.Router();
v2Router.get('/products', controllers.v2.getAllProducts);
v2Router.get('/products/:id', controllers.v2.getProductById);
v2Router.get('/products/search', controllers.v2.searchProducts); // New endpoint in v2
// Mount routers
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// Default route - redirect to latest version
app.get('/api/products', (req, res) => {
res.redirect(301, '/api/v2/products');
});
// Error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Something broke!' });
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
In this example:
- V1 provides basic product information
- V2 enhances product data by including category information
- V2 adds a new search endpoint not available in V1
- We have a redirect from the unversioned endpoint to the latest version
Handling Version Deprecation
It's also important to inform clients when a version is being deprecated:
// Middleware to warn about deprecated versions
function deprecationWarning(version, endOfLifeDate) {
return (req, res, next) => {
res.set({
'X-API-Deprecated': 'true',
'X-API-Deprecated-Warning': `API version ${version} is deprecated and will be discontinued on ${endOfLifeDate}. Please migrate to a newer version.`
});
next();
};
}
// Apply to deprecated version
v1Router.use(deprecationWarning('v1', '2023-12-31'));
Summary
API versioning is an essential practice for maintaining backward compatibility while allowing your API to evolve. We've explored several approaches to versioning:
- URL Path Versioning - The most explicit and widely used method
- Query Parameter Versioning - Good for simple versioning needs
- Header-based Versioning - Clean URLs but requires more client knowledge
- Content Negotiation - The most RESTful approach but more complex
Each approach has its pros and cons, and the best choice depends on your specific requirements and constraints. Many APIs use a combination of these approaches to provide flexibility.
Remember that versioning is ultimately about communication with your API consumers. Clear documentation and consistent practices will help ensure a smooth experience as your API evolves.
Exercises
- Implement a simple Express API with at least three endpoints and two versions using URL path versioning.
- Modify your API to support both URL path versioning and header-based versioning.
- Create a middleware that logs which API version is being used for each request.
- Implement a sunset strategy for an old API version, including appropriate warning headers.
- Design a system that automatically routes to the latest API version if no version is specified.
Additional Resources
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)