Implement API Versioning
A practical guide with examples for ASP.NET, Spring Boot, Express, Next.js, Flask, and Laravel.
Why API versioning matters
As your API evolves, you will change data shapes, endpoints, or behavior. Without versioning, you risk breaking existing clients. Versioning gives you a controlled way to introduce changes while continuing to support older clients.
Common versioning strategies
- URI/path versioning: /api/v1/... — explicit and cache-friendly.
- Header versioning: X-API-Version or custom media types — keeps URLs stable.
- Query parameter: ?v=2 — simple but less RESTful and can cause caching issues.
- Content negotiation (media type): Use custom Accept types, e.g. application/vnd.myapp.v2+json.
1. URL Path Versioning
In URL path versioning, the version number is included in the API endpoint URL. This approach is explicit and makes it clear which version of the API is being accessed.
GET /api/v1/usersPros
- Discoverability: The API version is immediately visible in the URL, making it easy for developers to understand which version they are using.
- Simplicity: It's straightforward to implement and test.
Cons
- Not fully RESTful: The version is treated as a resource, which goes against the idea of a stable, long-lasting URI for a resource. A change in version creates a different URI for the same resource.
- URL Clutter: The URL can become long and repetitive as more versions are added, for example,
api/v1/products, api/v2/products
Sample Code
First, install the NuGet package: dotnet add package Asp.Versioning.Mvc. Then, configure the versioning in Program.cs:
using Asp.Versioning;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddApiVersioning(options =>
{
options.ReportApiVersions = true;
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
});
var app = builder.Build();
app.MapControllers();
app.Run();
Next, define your controllers using the route template and version attribute. Note that different controllers are used:
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
public class UsersController : ControllerBase // Version 1 Controller
{
[HttpGet]
public IActionResult GetUsersV1()
{
return Ok(new[] { "User1", "User2" });
}
}
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
public class UsersV2Controller : ControllerBase // Version 2 Controller
{
[HttpGet]
public IActionResult GetUsersV2()
{
return Ok(new[] { "User1", "User2" });
}
}
2. Query String Versioning
In this approach, the API version is specified as a query string parameter. For example:
GET api/products?version=1.0This method is flexible and allows clients to easily switch between versions without changing the URL structure.
Pros
- Easy to implement and use
- Clients can test new versions without affecting existing functionality
- Clean URLs: The base URL remains the same for all versions of a resource, for example, api/products?api-version=1.0
Cons
- Can lead to versioning bloat in the API
- May require additional documentation for clients
- Not truly RESTful: Query parameters are intended for filtering or sorting, not for identifying the resource itself. This can violate the principle of a stable resource URI.
- Potential for conflicts: The version parameter might conflict with other query parameters used for filtering.
Sample Code
Configure the ApiVersionReader in Program.cs to use the query string:
using Asp.Versioning;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddApiVersioning(options =>
{
options.ReportApiVersions = true;
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ApiVersionReader = new QueryStringApiVersionReader("api-version"); //this is the change
});
var app = builder.Build();
app.MapControllers();
app.Run();
Your controllers don't need the version in the route. Instead, they can have the ApiVersion attribute on the controller or a specific action. You can use the [MapToApiVersion] attribute to map an action to a specific version.
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class UsersV2Controller : ControllerBase
{
[HttpGet]
[ApiVersion("1.0")]
public IActionResult GetUsersV1()
{
return Ok(new[] { "User1", "User2" });
}
[HttpGet]
[ApiVersion("2.0")]
public IActionResult GetUsersV2()
{
return Ok(new[] { "User1", "User2", "User3" });
}
}
3. HTTP Header Versioning
This approach places the version number in a custom HTTP header.
Pros
- RESTful: The URL and resource URI remain stable and clean.
- Flexibility: The versioning information is separated from the URL, making it a good choice for APIs that need to keep their URIs constant.
Cons
- Less intuitive: It's not as easily discoverable as URL-based versioning. A developer must know which specific header to use.
- More complex to test: You can't just type a URL in a browser; you need a tool like Postman or curl to set the custom header.
Sample Code
Change the ApiVersionReader in Program.cs to look for a header:
using Asp.Versioning;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddApiVersioning(options =>
{
options.ReportApiVersions = true;
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ApiVersionReader = new HeaderApiVersionReader("x-api-version"); //this is the change
});
var app = builder.Build();
app.MapControllers();
app.Run();
The controller code is the same as the query string example, as the routing logic is handled by the versioning library based on the configured reader.
Other Framework examples
Spring Boot
You can version by path or by headers. Path versioning is often simplest for public APIs.
// Spring Boot - Path versioning and custom header example
// Path versioning: /api/v1/hello and /api/v2/hello
package com.example.api;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1")
public class HelloV1Controller {
@GetMapping("/hello")
public Object hello() { return Map.of("version", "v1", "message", "hello v1"); }
}
@RestController
@RequestMapping("/api/v2")
public class HelloV2Controller {
@GetMapping("/hello")
public Object hello() { return Map.of("version", "v2", "message", "hello v2", "meta", Map.of("newField", true)); }
}
// Header versioning example (read X-API-Version header)
@RestController
@RequestMapping("/api/hello")
public class HelloHeaderController {
@GetMapping
public Object hello(@RequestHeader(value = "X-API-Version", required = false) String ver) {
if ("2".equals(ver)) return Map.of("version", "v2");
return Map.of("version", "v1");
}
}
// Explanation: Path versioning is simple and explicit. Header versioning keeps URLs clean but requires clients to set headers. Choose based on client constraints.Explanation: Path-based controllers clearly separate behavior. Header negotiation is flexible for clients that cannot change URLs, but requires documentation and client support.
Express (Node.js)
Mount routers under different path prefixes, and use middleware for more advanced negotiation.
// Express.js - Route prefixes and middleware for versioning
const express = require('express');
const app = express();
// v1 router
const v1 = express.Router();
v1.get('/products', (req, res) => res.json({ version: 'v1', products: ['a','b'] }));
// v2 router
const v2 = express.Router();
v2.get('/products', (req, res) => res.json({ version: 'v2', items: [{ id:1, name:'a' }] }));
app.use('/api/v1', v1);
app.use('/api/v2', v2);
// Optional middleware: negotiate version from Accept header: application/vnd.myapp.v2+json
app.use((req, res, next) => {
const accept = req.get('Accept') || '';
const match = accept.match(/vnd.myapp.v(d+)+json/);
if (match) req.versionFromHeader = match[1];
next();
});
module.exports = app;
// Explanation: Mount routers under different path prefixes for clear separation. Middleware can read headers for advanced negotiation.Explanation: Separate routers keep codebases maintainable. Header parsing middleware enables content-negotiation style versioning when needed.
Next.js
File-based routing makes versioned API paths easy. Alternatively, use a dispatcher that inspects headers or query params.
// Next.js - Versioned API routes by path and HOF dispatcher
// File: pages/api/v1/hello.ts
export default function handlerV1(req, res) {
res.status(200).json({ version: 'v1', message: 'Hello v1' });
}
// File: pages/api/v2/hello.ts
export default function handlerV2(req, res) {
res.status(200).json({ version: 'v2', message: 'Hello v2', extra: true });
}
// Alternative: single route that inspects header and dispatches
export function withVersionedApi(map) {
return (req, res) => {
const v = req.query.v || req.headers['x-api-version'] || '1';
const fn = map[v] || map['1'];
return fn(req, res);
}
}
// Usage example in pages/api/hello.ts:
// export default withVersionedApi({ '1': handlerV1, '2': handlerV2 });
// Explanation: Next.js file-system routing makes path-based versioning straightforward. For single-file dispatch, inspect query or headers.Explanation: The examples show both separate files per version and a single-file dispatcher. Choose whichever fits your project's structure.
Flask (Python)
Blueprints group versioned routes, while header negotiation allows a single endpoint to serve multiple versions.
# Flask - Blueprints per version and header negotiation
from flask import Flask, Blueprint, jsonify, request
app = Flask(__name__)
v1 = Blueprint('v1', __name__, url_prefix='/api/v1')
@v1.route('/users')
def users_v1():
return jsonify(version='v1', users=['alice','bob'])
v2 = Blueprint('v2', __name__, url_prefix='/api/v2')
@v2.route('/users')
def users_v2():
return jsonify(version='v2', users=[{'id':1,'name':'alice'}])
app.register_blueprint(v1)
app.register_blueprint(v2)
# Header-based negotiation example
@app.route('/api/users')
def users():
ver = request.headers.get('X-API-Version', '1')
if ver == '2':
return users_v2()
return users_v1()
if __name__ == '__main__':
app.run()
# Explanation: Blueprints group routes by version; header negotiation allows single URL for clients that prefer it.Explanation: Blueprints are lightweight and fit naturally with path versioning. Header-based dispatch keeps endpoints stable for clients that can't change URLs.
Laravel (PHP)
Use route group prefixes for path versioning and middleware to centralize negotiation logic.
// Laravel - Route group prefixes and middleware example
// routes/api.php
use IlluminateSupportFacadesRoute;
Route::prefix('v1')->group(function() {
Route::get('/posts', [AppHttpControllersPostControllerV1::class, 'index']);
});
Route::prefix('v2')->group(function() {
Route::get('/posts', [AppHttpControllersPostControllerV2::class, 'index']);
});
// Middleware to read Accept header or X-API-Version
// app/Http/Middleware/ApiVersion.php (simplified)
public function handle($request, Closure $next)
{
$ver = $request->header('X-API-Version') ?? '1';
// You can rewrite the request URI or set a request attribute
$request->attributes->set('api_version', $ver);
return $next($request);
}
// Explanation: Laravel route groups make path versioning easy. Middleware can be used to centralize negotiation and route selection.Explanation: Route groups keep version-specific controllers organized. Middleware can decorate the request with the negotiated version for downstream logic.
Migration tips
- Deprecation policy: Communicate timelines for old versions and support windows.
- Backwards compatibility: Prefer additive changes; use new endpoints for breaking changes.
- Documentation: Clearly document all supported versions, examples, and how clients can migrate.
- Monitoring: Track usage per version so you know when it's safe to remove an old version.
Conclusion — Key concepts
API versioning is essential to evolve your API without breaking clients. The main decisions are how to surface versions (URI, header, query, or media type) and how to organize server code (separate routes, blueprints, controllers, or dispatchers). Pick a strategy that fits your clients and infrastructure, document it clearly, implement migration and deprecation practices, and monitor version usage. With these in place you can iterate on your API safely.