API Versioning Pattern

Implement sustainable API versioning strategies using URL paths, headers, or content negotiation with deprecation handling and documentation.

Overview#

API versioning allows you to evolve your API while maintaining backward compatibility for existing clients. This pattern covers multiple versioning strategies and their implementation in Next.js.

When to use:

  • Making breaking changes to API responses
  • Supporting multiple client versions simultaneously
  • Gradual API evolution over time
  • Long-term API maintenance

Key features:

  • URL-based versioning (most common)
  • Header-based versioning (cleaner URLs)
  • Content negotiation (flexible)
  • Deprecation notices and sunset headers

Code Example#

URL-Based Versioning#

1// app/api/v1/users/route.ts 2import { NextRequest, NextResponse } from 'next/server' 3import { prisma } from '@/lib/db' 4 5// V1 API - returns basic user data 6export async function GET() { 7 const users = await prisma.user.findMany({ 8 select: { 9 id: true, 10 name: true, 11 email: true 12 } 13 }) 14 15 return NextResponse.json(users) 16} 17 18// app/api/v2/users/route.ts 19// V2 API - returns extended user data with different structure 20export async function GET() { 21 const users = await prisma.user.findMany({ 22 select: { 23 id: true, 24 name: true, 25 email: true, 26 createdAt: true, 27 profile: { 28 select: { 29 avatar: true, 30 bio: true 31 } 32 } 33 } 34 }) 35 36 // V2 uses different response format 37 return NextResponse.json({ 38 data: users, 39 meta: { 40 total: users.length, 41 version: 'v2' 42 } 43 }) 44}

Shared Version Logic#

1// lib/api/versions.ts 2import { NextResponse } from 'next/server' 3 4export type ApiVersion = 'v1' | 'v2' | 'v3' 5 6interface VersionedResponse<T> { 7 v1: (data: T) => object 8 v2: (data: T) => object 9 v3?: (data: T) => object 10} 11 12export function formatResponse<T>( 13 version: ApiVersion, 14 data: T, 15 formatters: VersionedResponse<T> 16) { 17 const formatter = formatters[version] 18 if (!formatter) { 19 throw new Error(`Unsupported API version: ${version}`) 20 } 21 return formatter(data) 22} 23 24// Usage example 25// lib/api/users/formatters.ts 26import { User } from '@prisma/client' 27 28export const userFormatters = { 29 v1: (user: User) => ({ 30 id: user.id, 31 name: user.name, 32 email: user.email 33 }), 34 35 v2: (user: User & { profile?: { avatar?: string } }) => ({ 36 id: user.id, 37 displayName: user.name, // renamed field 38 email: user.email, 39 avatar: user.profile?.avatar ?? null, 40 createdAt: user.createdAt.toISOString() 41 }) 42}

Header-Based Versioning#

1// middleware.ts 2import { NextRequest, NextResponse } from 'next/server' 3 4export function middleware(request: NextRequest) { 5 const version = request.headers.get('API-Version') ?? 'v1' 6 const validVersions = ['v1', 'v2', 'v3'] 7 8 if (!validVersions.includes(version)) { 9 return NextResponse.json( 10 { error: `Invalid API version: ${version}` }, 11 { status: 400 } 12 ) 13 } 14 15 // Add version to request headers for route handlers 16 const requestHeaders = new Headers(request.headers) 17 requestHeaders.set('x-api-version', version) 18 19 return NextResponse.next({ 20 request: { headers: requestHeaders } 21 }) 22} 23 24export const config = { 25 matcher: '/api/:path*' 26} 27 28// app/api/users/route.ts 29import { NextRequest, NextResponse } from 'next/server' 30import { formatResponse } from '@/lib/api/versions' 31import { userFormatters } from '@/lib/api/users/formatters' 32 33export async function GET(request: NextRequest) { 34 const version = request.headers.get('x-api-version') as 'v1' | 'v2' 35 36 const users = await prisma.user.findMany({ 37 include: version === 'v2' ? { profile: true } : undefined 38 }) 39 40 const formatted = users.map(user => 41 formatResponse(version, user, userFormatters) 42 ) 43 44 const response = NextResponse.json(formatted) 45 response.headers.set('API-Version', version) 46 47 return response 48}

Content Negotiation Versioning#

1// lib/api/content-negotiation.ts 2import { NextRequest } from 'next/server' 3 4export function parseAcceptHeader(request: NextRequest): { 5 version: string 6 format: string 7} { 8 const accept = request.headers.get('Accept') ?? 'application/json' 9 10 // Parse: application/vnd.myapi.v2+json 11 const match = accept.match(/application\/vnd\.myapi\.v(\d+)\+(\w+)/) 12 13 if (match) { 14 return { 15 version: `v${match[1]}`, 16 format: match[2] 17 } 18 } 19 20 return { version: 'v1', format: 'json' } 21} 22 23// app/api/users/route.ts 24export async function GET(request: NextRequest) { 25 const { version, format } = parseAcceptHeader(request) 26 27 const users = await prisma.user.findMany() 28 29 const formatted = users.map(user => 30 formatResponse(version as 'v1' | 'v2', user, userFormatters) 31 ) 32 33 const contentType = `application/vnd.myapi.${version}+${format}` 34 35 return new Response(JSON.stringify(formatted), { 36 headers: { 37 'Content-Type': contentType 38 } 39 }) 40}

Version Deprecation#

1// lib/api/deprecation.ts 2import { NextResponse } from 'next/server' 3 4interface DeprecationInfo { 5 deprecated: boolean 6 sunset?: string // ISO date when version will be removed 7 successor?: string // Recommended version to migrate to 8 message?: string 9} 10 11const versionStatus: Record<string, DeprecationInfo> = { 12 v1: { 13 deprecated: true, 14 sunset: '2024-12-31', 15 successor: 'v2', 16 message: 'Please migrate to v2 API before December 31, 2024' 17 }, 18 v2: { deprecated: false }, 19 v3: { deprecated: false } 20} 21 22export function addDeprecationHeaders( 23 response: NextResponse, 24 version: string 25): NextResponse { 26 const info = versionStatus[version] 27 28 if (info?.deprecated) { 29 response.headers.set('Deprecation', 'true') 30 if (info.sunset) { 31 response.headers.set('Sunset', info.sunset) 32 } 33 if (info.message) { 34 response.headers.set('X-Deprecation-Notice', info.message) 35 } 36 if (info.successor) { 37 response.headers.set( 38 'Link', 39 `</api/${info.successor}>; rel="successor-version"` 40 ) 41 } 42 } 43 44 return response 45} 46 47// Usage in route handler 48export async function GET(request: NextRequest) { 49 const version = request.headers.get('x-api-version') ?? 'v1' 50 51 const data = await fetchData() 52 53 let response = NextResponse.json(data) 54 response = addDeprecationHeaders(response, version) 55 56 return response 57}

Version-Specific Middleware#

1// lib/api/version-middleware.ts 2import { NextRequest, NextResponse } from 'next/server' 3 4type Handler = (req: NextRequest) => Promise<NextResponse> 5type VersionedHandlers = Record<string, Handler> 6 7export function createVersionedHandler(handlers: VersionedHandlers) { 8 return async (request: NextRequest) => { 9 const version = request.headers.get('x-api-version') ?? 'v1' 10 11 const handler = handlers[version] 12 if (!handler) { 13 return NextResponse.json( 14 { 15 error: 'Unsupported API version', 16 supportedVersions: Object.keys(handlers) 17 }, 18 { status: 400 } 19 ) 20 } 21 22 return handler(request) 23 } 24} 25 26// app/api/products/route.ts 27import { createVersionedHandler } from '@/lib/api/version-middleware' 28 29const v1Handler = async (request: NextRequest) => { 30 const products = await prisma.product.findMany() 31 return NextResponse.json(products) 32} 33 34const v2Handler = async (request: NextRequest) => { 35 const products = await prisma.product.findMany({ 36 include: { variants: true, reviews: true } 37 }) 38 return NextResponse.json({ 39 data: products, 40 included: { totalCount: products.length } 41 }) 42} 43 44export const GET = createVersionedHandler({ 45 v1: v1Handler, 46 v2: v2Handler 47})

API Documentation per Version#

1// lib/api/openapi.ts 2import { OpenAPIObject } from 'openapi3-ts/oas31' 3 4export const openApiSpecs: Record<string, OpenAPIObject> = { 5 v1: { 6 openapi: '3.1.0', 7 info: { 8 title: 'My API', 9 version: '1.0.0', 10 description: 'API v1 (deprecated)' 11 }, 12 paths: { 13 '/api/v1/users': { 14 get: { 15 summary: 'List users', 16 responses: { 17 '200': { 18 description: 'List of users', 19 content: { 20 'application/json': { 21 schema: { 22 type: 'array', 23 items: { $ref: '#/components/schemas/UserV1' } 24 } 25 } 26 } 27 } 28 } 29 } 30 } 31 }, 32 components: { 33 schemas: { 34 UserV1: { 35 type: 'object', 36 properties: { 37 id: { type: 'string' }, 38 name: { type: 'string' }, 39 email: { type: 'string' } 40 } 41 } 42 } 43 } 44 }, 45 v2: { 46 openapi: '3.1.0', 47 info: { 48 title: 'My API', 49 version: '2.0.0', 50 description: 'API v2 (current)' 51 }, 52 // ... v2 spec 53 } 54} 55 56// app/api/docs/[version]/route.ts 57import { NextRequest, NextResponse } from 'next/server' 58import { openApiSpecs } from '@/lib/api/openapi' 59 60export async function GET( 61 request: NextRequest, 62 { params }: { params: { version: string } } 63) { 64 const spec = openApiSpecs[params.version] 65 66 if (!spec) { 67 return NextResponse.json( 68 { error: 'Version not found' }, 69 { status: 404 } 70 ) 71 } 72 73 return NextResponse.json(spec) 74}

Usage Instructions#

  1. Choose versioning strategy: URL paths (recommended for public APIs), headers (cleaner), or content negotiation
  2. Create version formatters: Define how data is transformed for each version
  3. Implement route handlers: Use versioned handlers or separate route files
  4. Add deprecation handling: Notify clients of deprecated versions
  5. Document each version: Maintain separate documentation per version

Best Practices#

  1. Start with v1 - Plan for versioning from the beginning
  2. Use semantic versioning - Major versions for breaking changes
  3. Deprecate gracefully - Give clients time to migrate
  4. Document differences - Clearly explain what changed between versions
  5. Support multiple versions - Keep at least 2 versions active
  6. Set sunset dates - Communicate when versions will be removed
  7. Monitor usage - Track which versions clients use