Skip to main content
Back to Blog
Tutorials
3 min read
November 30, 2024

How to Implement API Versioning in Next.js

Set up API versioning in Next.js using URL path versioning, header versioning, and query parameter approaches with migration strategies.

Ryel Banfield

Founder & Lead Developer

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

  1. Use URL path versioning for public APIs — it is the most discoverable
  2. Never remove a version without notice — deprecate first, then sunset
  3. Keep shared business logic — only vary serialization between versions
  4. Document all versions with OpenAPI/Swagger specs
  5. Set sunset dates and communicate them via response headers
  6. Monitor version usage to know when it is safe to remove old versions
  7. 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.

APIversioningRESTNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles