Back to blog
apiversioningbackward-compatibilitysaas-architectureintegrations

API Versioning and Backward Compatibility: How to Evolve Your API Without Breaking Customers

By SaaS Masters22 maart 20266 min read

Why API versioning is crucial

Your SaaS is running, customers are connected through your API, and then the moment comes: you need to make a breaking change. Maybe you need to rename a field, restructure an endpoint, or introduce an entirely new data model. Without a solid versioning strategy, you'll break every customer's integration in one go.

API versioning isn't a luxury — it's a requirement the moment your first external customer uses your API. In this article, we'll dive deep into the strategies, patterns, and practical implementations you need.

The three main strategies

1. URL path versioning

The most common and visible approach:

GET /api/v1/users
GET /api/v2/users

Pros:

  • Extremely clear and visible
  • Easy to test and document
  • Caching works out-of-the-box (different URLs = different cache entries)

Cons:

  • Can lead to code duplication if you're not careful
  • Clients need to update URLs when upgrading

Implementation in Next.js:

// app/api/v1/users/route.ts
export async function GET() {
  const users = await db.user.findMany({
    select: { id: true, name: true, email: true }
  });
  return Response.json(users);
}

// app/api/v2/users/route.ts
export async function GET() {
  const users = await db.user.findMany({
    select: {
      id: true,
      firstName: true,  // v2: split name field
      lastName: true,
      email: true,
      organization: { select: { id: true, name: true } }
    }
  });
  return Response.json({ data: users, meta: { total: users.length } });
}

2. Header-based versioning

Use a custom header to indicate the version:

GET /api/users
Accept-Version: 2

Pros:

  • Clean URLs that don't change
  • Easy to set a default version

Cons:

  • Less visible — easy to forget in documentation
  • Harder to test in the browser
// middleware.ts
import { NextResponse } from 'next/server';

export function middleware(request: Request) {
  const version = request.headers.get('Accept-Version') || '1';
  const response = NextResponse.next();
  response.headers.set('X-API-Version', version);
  return response;
}

// In your route handler
export async function GET(request: Request) {
  const version = request.headers.get('Accept-Version') || '1';

  if (version === '2') {
    return handleV2(request);
  }
  return handleV1(request);
}

3. Query parameter versioning

GET /api/users?version=2

Simple but less elegant. Works well for internal APIs, but is discouraged for public APIs because it pollutes the URL.

The real challenge: backward compatibility

Versioning is only half the story. The real challenge is backward compatibility — ensuring existing integrations keep working while your API evolves.

The golden rules

1. Add, don't remove

// ✅ Good: add a new field
interface UserV1 {
  id: string;
  name: string;
  email: string;
}

interface UserV2 extends UserV1 {
  firstName: string;    // New
  lastName: string;     // New
  // 'name' still exists for backward compatibility
}

2. Make new fields optional

// ✅ Good: optional new field in request body
interface CreateUserRequest {
  name: string;
  email: string;
  organizationId?: string;  // New, but optional
}

3. Use a deprecation strategy

export async function GET(request: Request) {
  const response = await getUsers();

  // Mark deprecated fields in response headers
  return Response.json(response, {
    headers: {
      'Deprecation': 'true',
      'Sunset': 'Sat, 01 Jun 2026 00:00:00 GMT',
      'Link': '</api/v2/users>; rel=successor-version'
    }
  });
}

Building a versioning system with the adapter pattern

The most powerful approach is the adapter pattern: build one internal representation and translate it per version.

// lib/api/adapters/user-adapter.ts

interface InternalUser {
  id: string;
  firstName: string;
  lastName: string;
  email: string;
  organizationId: string;
  createdAt: Date;
  metadata: Record<string, unknown>;
}

// V1 adapter - the original format
function toV1(user: InternalUser) {
  return {
    id: user.id,
    name: `${user.firstName} ${user.lastName}`,
    email: user.email,
  };
}

// V2 adapter - extended format
function toV2(user: InternalUser) {
  return {
    id: user.id,
    firstName: user.firstName,
    lastName: user.lastName,
    email: user.email,
    organizationId: user.organizationId,
    createdAt: user.createdAt.toISOString(),
  };
}

// V3 adapter - full format with metadata
function toV3(user: InternalUser) {
  return {
    ...toV2(user),
    metadata: user.metadata,
    _links: {
      self: `/api/v3/users/${user.id}`,
      organization: `/api/v3/organizations/${user.organizationId}`,
    }
  };
}

const adapters: Record<string, (user: InternalUser) => unknown> = {
  '1': toV1,
  '2': toV2,
  '3': toV3,
};

export function serializeUser(user: InternalUser, version: string) {
  const adapter = adapters[version] || adapters['1'];
  return adapter(user);
}

Customer migration strategy

A good migration strategy is just as important as the technical implementation:

1. Communicate early

// Send deprecation warnings in response headers
// AND log which customers are still using old versions

async function trackApiVersionUsage(
  apiKey: string,
  version: string,
  endpoint: string
) {
  await db.apiUsageLog.create({
    data: {
      apiKey,
      version,
      endpoint,
      timestamp: new Date(),
    }
  });
}

2. Offer a migration period

A typical timeline:

  • Month 1-2: New version available, old version fully functional
  • Month 3-4: Deprecation headers on old version, migration docs available
  • Month 5: Warning emails to customers still using the old version
  • Month 6: Old version decommissioned (with grace period)

3. Provide migration tools

// An endpoint that helps customers test their integration
// POST /api/migration/validate
export async function POST(request: Request) {
  const body = await request.json();
  const { fromVersion, toVersion, sampleRequest } = body;

  // Simulate the request against both versions
  const oldResponse = await simulateRequest(sampleRequest, fromVersion);
  const newResponse = await simulateRequest(sampleRequest, toVersion);

  // Show the differences
  return Response.json({
    compatible: isCompatible(oldResponse, newResponse),
    differences: getDifferences(oldResponse, newResponse),
    migrationGuide: getMigrationSteps(fromVersion, toVersion),
  });
}

Common mistakes

❌ Supporting too many versions simultaneously

Every version you maintain costs time and energy. Aim for a maximum of 2-3 active versions.

❌ No sunset policy

Without a clear policy on when old versions stop, you build up technical debt that grows exponentially.

❌ Forgetting to version webhooks

If you send webhooks, those need to be versioned too:

async function sendWebhook(event: string, data: unknown, subscription: WebhookSubscription) {
  const payload = serializeWebhookPayload(
    event,
    data,
    subscription.apiVersion  // Send the format the customer expects
  );

  await fetch(subscription.url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Version': subscription.apiVersion,
      'X-Webhook-Signature': sign(payload),
    },
    body: JSON.stringify(payload),
  });
}

❌ Not maintaining a changelog

// Automatically generate a changelog from your codebase
// changelog/v2.md
/**
 * ## API v2 Changelog
 *
 * ### Breaking Changes
 * - \`name\` field split into \`firstName\` and \`lastName\`
 * - Response wrapper: all responses now contain \`{ data, meta }\`
 *
 * ### New Features
 * - Organization data available via \`?include=organization\`
 * - Pagination metadata in \`meta\` object
 *
 * ### Deprecated
 * - \`name\` field (still available, will be removed in v3)
 */

Conclusion

API versioning is one of those things you'd rather implement too early than too late. Start with URL path versioning (it's the most explicit), use the adapter pattern to avoid code duplication, and establish a clear deprecation policy from day one.

The best API versioning strategy is one your customers barely notice — because transitions are smooth, communication is clear, and migration tools work excellently.

Key takeaways:

  • Choose one versioning strategy and be consistent
  • Add, don't remove — backward compatibility first
  • Use the adapter pattern for clean code
  • Communicate early and provide migration tools
  • Maximum 2-3 active versions at a time
  • Don't forget to version your webhooks