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#
- Choose versioning strategy: URL paths (recommended for public APIs), headers (cleaner), or content negotiation
- Create version formatters: Define how data is transformed for each version
- Implement route handlers: Use versioned handlers or separate route files
- Add deprecation handling: Notify clients of deprecated versions
- Document each version: Maintain separate documentation per version
Best Practices#
- Start with v1 - Plan for versioning from the beginning
- Use semantic versioning - Major versions for breaking changes
- Deprecate gracefully - Give clients time to migrate
- Document differences - Clearly explain what changed between versions
- Support multiple versions - Keep at least 2 versions active
- Set sunset dates - Communicate when versions will be removed
- Monitor usage - Track which versions clients use
Related Patterns#
- Route Handler - API endpoint implementation
- Middleware - Request preprocessing
- Error Handling - Consistent error responses
- OpenAPI - API documentation