API versioning lets you evolve your API without breaking existing clients. Here are several approaches for Next.js.
Approach 1: URL Path Versioning
The most common and explicit approach.
Directory Structure
app/
api/
v1/
users/
route.ts
products/
route.ts
v2/
users/
route.ts
products/
route.ts
V1 Implementation
// app/api/v1/users/route.ts
import { NextResponse } from "next/server";
export async function GET() {
const users = await getUsers();
// V1 response format
return NextResponse.json({
data: users.map((user) => ({
id: user.id,
name: `${user.firstName} ${user.lastName}`,
email: user.email,
})),
});
}
V2 Implementation
// app/api/v2/users/route.ts
import { NextResponse } from "next/server";
export async function GET() {
const users = await getUsers();
// V2 response format with more details
return NextResponse.json({
data: users.map((user) => ({
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
avatar: user.avatar,
createdAt: user.createdAt,
})),
meta: {
total: users.length,
apiVersion: "2.0",
},
});
}
Approach 2: Header-Based Versioning
Use a custom header to specify the API version.
// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const version = request.headers.get("X-API-Version") ?? "2";
const users = await getUsers();
switch (version) {
case "1":
return NextResponse.json({
data: users.map((user) => ({
id: user.id,
name: `${user.firstName} ${user.lastName}`,
email: user.email,
})),
});
case "2":
default:
return NextResponse.json({
data: users.map((user) => ({
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
avatar: user.avatar,
createdAt: user.createdAt,
})),
meta: { apiVersion: "2.0" },
});
}
}
Approach 3: Version Router Middleware
Centralize version routing with middleware.
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
const CURRENT_VERSION = "v2";
const SUPPORTED_VERSIONS = ["v1", "v2"];
const DEPRECATED_VERSIONS = ["v1"];
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// Only apply to API routes
if (!pathname.startsWith("/api/")) return NextResponse.next();
// Extract version from path
const versionMatch = pathname.match(/^\/api\/(v\d+)\//);
const version = versionMatch?.[1];
// If no version in path, redirect to current version
if (!version && pathname.startsWith("/api/") && !pathname.startsWith(`/api/${CURRENT_VERSION}/`)) {
const versionedPath = pathname.replace("/api/", `/api/${CURRENT_VERSION}/`);
return NextResponse.rewrite(new URL(versionedPath, request.url));
}
// Check if version is supported
if (version && !SUPPORTED_VERSIONS.includes(version)) {
return NextResponse.json(
{
error: `API version '${version}' is not supported. Supported versions: ${SUPPORTED_VERSIONS.join(", ")}`,
},
{ status: 400 }
);
}
const response = NextResponse.next();
// Add deprecation warning headers
if (version && DEPRECATED_VERSIONS.includes(version)) {
response.headers.set("Deprecation", "true");
response.headers.set(
"Sunset",
new Date("2027-01-01").toUTCString()
);
response.headers.set(
"Link",
`</api/${CURRENT_VERSION}${pathname.replace(`/api/${version}`, "")}>; rel="successor-version"`
);
}
// Add version header to all responses
response.headers.set("X-API-Version", version ?? CURRENT_VERSION);
return response;
}
Approach 4: Shared Handler with Transformers
Keep business logic shared and only vary the response format.
// lib/api/transformers.ts
interface User {
id: string;
firstName: string;
lastName: string;
email: string;
avatar: string | null;
createdAt: Date;
}
type ApiVersion = "1" | "2";
const userTransformers: Record<ApiVersion, (user: User) => unknown> = {
"1": (user) => ({
id: user.id,
name: `${user.firstName} ${user.lastName}`,
email: user.email,
}),
"2": (user) => ({
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
avatar: user.avatar,
createdAt: user.createdAt.toISOString(),
}),
};
export function transformUser(user: User, version: ApiVersion) {
const transformer = userTransformers[version];
return transformer(user);
}
export function transformUsers(users: User[], version: ApiVersion) {
return users.map((user) => transformUser(user, version));
}
Usage
// app/api/v1/users/route.ts
import { NextResponse } from "next/server";
import { transformUsers } from "@/lib/api/transformers";
export async function GET() {
const users = await getUsers();
return NextResponse.json({ data: transformUsers(users, "1") });
}
Approach 5: Version-Aware Validation
Different versions may accept different request bodies.
// lib/api/validators.ts
import { z } from "zod";
export const createUserSchemas = {
v1: z.object({
name: z.string().min(1),
email: z.string().email(),
}),
v2: z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
avatar: z.string().url().optional(),
}),
};
// Transform v1 input to internal format
export function normalizeCreateUserInput(data: unknown, version: "v1" | "v2") {
if (version === "v1") {
const parsed = createUserSchemas.v1.parse(data);
const [firstName = "", ...rest] = parsed.name.split(" ");
return {
firstName,
lastName: rest.join(" ") || firstName,
email: parsed.email,
};
}
return createUserSchemas.v2.parse(data);
}
Deprecation Response Helper
// lib/api/response.ts
import { NextResponse } from "next/server";
interface ApiResponseOptions {
version?: string;
deprecated?: boolean;
sunsetDate?: string;
}
export function apiResponse(
data: unknown,
status: number = 200,
options: ApiResponseOptions = {}
) {
const response = NextResponse.json(data, { status });
if (options.version) {
response.headers.set("X-API-Version", options.version);
}
if (options.deprecated) {
response.headers.set("Deprecation", "true");
if (options.sunsetDate) {
response.headers.set("Sunset", new Date(options.sunsetDate).toUTCString());
}
response.headers.set(
"Warning",
'299 - "This API version is deprecated. Please migrate to the latest version."'
);
}
return response;
}
Best Practices
- Use URL path versioning for public APIs — it is the most discoverable
- Never remove a version without notice — deprecate first, then sunset
- Keep shared business logic — only vary serialization between versions
- Document all versions with OpenAPI/Swagger specs
- Set sunset dates and communicate them via response headers
- Monitor version usage to know when it is safe to remove old versions
- Version from the start even if you only have one version
Need Help with API Design?
We design and build production-grade APIs with versioning, documentation, and developer experience in mind. Contact us to discuss your API strategy.