SaaS API Versioning: URL Versioning, Header Negotiation, and Deprecation Strategy
Implement API versioning for your SaaS. Covers URL path versioning vs header negotiation, shared service layer pattern, deprecation warnings, sunset headers, version routing in Next.js, and migration guides for API consumers.
API versioning is one of those decisions that's easy to get wrong and expensive to fix. Version too early and you maintain dead code forever. Version too late and you break integrations you didn't know existed. The right approach depends on your API consumers: are they internal apps you control, or third-party developers who've built on your API?
This guide covers URL versioning (the practical default), header-based content negotiation (for purists), a shared service layer so logic isn't duplicated, and a deprecation strategy that gives consumers time to migrate.
Versioning Strategies Compared
| Strategy | URL example | Pros | Cons |
|---|---|---|---|
| URL path | /v1/users | Easy to test, bookmark, log | "Unclean" URLs, not REST-pure |
| Query parameter | /users?version=1 | Easy to add | Easy to miss, cache-unfriendly |
| Accept header | Accept: application/vnd.acme.v1+json | REST-correct | Hard to test in browser, complex routing |
| Custom header | X-API-Version: 1 | Simple | Not cacheable, non-standard |
Our recommendation: URL path versioning (/v1/, /v2/) for public APIs. It's explicit, easy for consumers to understand, easy to document, and trivial to route. Save header negotiation for internal APIs where you control all clients.
URL Path Versioning in Next.js
app/
โโโ api/
โ โโโ v1/
โ โ โโโ users/
โ โ โ โโโ route.ts
โ โ โโโ projects/
โ โ โ โโโ route.ts
โ โ โโโ middleware.ts # v1-specific middleware
โ โโโ v2/
โ โ โโโ users/
โ โ โ โโโ route.ts # Breaking changes from v1
โ โ โโโ middleware.ts
โ โโโ _shared/ # Shared service layer (not versioned)
โ โโโ user-service.ts
โ โโโ project-service.ts
๐ SaaS MVP in 8 Weeks โ Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ all handled by one senior team.
- Week 1โ2: Architecture design + wireframes
- Week 3โ6: Core features built + tested
- Week 7โ8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
Shared Service Layer (Critical Pattern)
The most common versioning mistake: copy-pasting logic into each version's route handler. When you fix a bug, you fix it in one version but forget the other.
The correct pattern: one service layer, multiple version adapters.
// app/api/_shared/user-service.ts
// Business logic โ version-agnostic
import { prisma } from "@/lib/prisma";
import { hash } from "bcryptjs";
import type { Prisma } from "@prisma/client";
export interface CreateUserInput {
email: string;
name: string;
password: string;
organizationId?: string;
}
export interface UpdateUserInput {
name?: string;
email?: string;
avatarUrl?: string;
}
export const userService = {
async findById(id: string) {
return prisma.user.findUnique({
where: { id },
include: { organization: { select: { id: true, name: true, plan: true } } },
});
},
async findByEmail(email: string) {
return prisma.user.findUnique({ where: { email: email.toLowerCase() } });
},
async create(input: CreateUserInput) {
const passwordHash = await hash(input.password, 12);
return prisma.user.create({
data: {
email: input.email.toLowerCase(),
name: input.name,
passwordHash,
organizationId: input.organizationId,
},
});
},
async update(id: string, input: UpdateUserInput) {
return prisma.user.update({ where: { id }, data: input });
},
async list(params: {
organizationId: string;
page: number;
limit: number;
search?: string;
}) {
const where: Prisma.UserWhereInput = {
organizationId: params.organizationId,
...(params.search
? {
OR: [
{ name: { contains: params.search, mode: "insensitive" } },
{ email: { contains: params.search, mode: "insensitive" } },
],
}
: {}),
};
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
skip: (params.page - 1) * params.limit,
take: params.limit,
orderBy: { createdAt: "desc" },
}),
prisma.user.count({ where }),
]);
return { users, total, page: params.page, limit: params.limit };
},
};
v1 Route Handler
// app/api/v1/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { userService } from "../../_shared/user-service";
import { addDeprecationHeaders } from "../../_shared/deprecation";
// v1 response shape (original design)
function serializeUserV1(user: any) {
return {
id: user.id,
email: user.email,
name: user.name,
// v1: flat organization fields
organization_id: user.organizationId,
organization_name: user.organization?.name ?? null,
created_at: user.createdAt.toISOString(),
updated_at: user.updatedAt.toISOString(),
// v1: camelCase NOT used โ underscored
};
}
export async function GET(req: NextRequest) {
const session = await auth(req);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = req.nextUrl;
const page = Number(searchParams.get("page") ?? 1);
const limit = Math.min(Number(searchParams.get("limit") ?? 20), 100);
const search = searchParams.get("search") ?? undefined;
const result = await userService.list({
organizationId: session.organizationId,
page,
limit,
search,
});
const res = NextResponse.json({
data: result.users.map(serializeUserV1),
meta: {
total: result.total,
page: result.page,
limit: result.limit,
total_pages: Math.ceil(result.total / result.limit),
},
});
// Add deprecation warning headers
addDeprecationHeaders(res, {
version: "v1",
sunsetDate: "2028-03-31",
successorVersion: "v2",
documentationUrl: "https://docs.acme.com/api/migration/v1-to-v2",
});
return res;
}
๐ก The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity โ you own everything
v2 Route Handler
// app/api/v2/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { userService } from "../../_shared/user-service";
// v2: camelCase, nested organization object, cursor pagination
function serializeUserV2(user: any) {
return {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.avatarUrl ?? null,
// v2: nested organization object (breaking change from v1)
organization: user.organization
? {
id: user.organization.id,
name: user.organization.name,
plan: user.organization.plan,
}
: null,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
};
}
export async function GET(req: NextRequest) {
const session = await auth(req);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = req.nextUrl;
const page = Number(searchParams.get("page") ?? 1);
const limit = Math.min(Number(searchParams.get("limit") ?? 20), 100);
const search = searchParams.get("search") ?? undefined;
const result = await userService.list({
organizationId: session.organizationId,
page,
limit,
search,
});
return NextResponse.json({
data: result.users.map(serializeUserV2),
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: Math.ceil(result.total / result.limit),
hasNextPage: result.page * result.limit < result.total,
hasPreviousPage: result.page > 1,
},
});
}
Deprecation Headers
// app/api/_shared/deprecation.ts
import type { NextResponse } from "next/server";
interface DeprecationConfig {
version: string;
sunsetDate: string; // ISO date string: "2028-03-31"
successorVersion: string;
documentationUrl: string;
}
export function addDeprecationHeaders(
res: NextResponse,
config: DeprecationConfig
): void {
// RFC 8594: Sunset header โ when the API version will be removed
res.headers.set("Sunset", new Date(config.sunsetDate).toUTCString());
// Deprecation header โ when the deprecation was announced
res.headers.set("Deprecation", new Date().toUTCString());
// Link header โ points to migration docs and successor
res.headers.set(
"Link",
[
`<${config.documentationUrl}>; rel="deprecation"`,
`<https://api.acme.com/${config.successorVersion}>; rel="successor-version"`,
].join(", ")
);
// Custom header for visibility in dashboards
res.headers.set("X-API-Deprecated", "true");
res.headers.set("X-API-Sunset", config.sunsetDate);
res.headers.set("X-API-Successor", config.successorVersion);
}
Version Routing Middleware
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
// Default version for unversioned requests (e.g., /api/users โ /api/v2/users)
const DEFAULT_VERSION = "v2";
const SUPPORTED_VERSIONS = ["v1", "v2"];
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
// Skip non-API routes
if (!pathname.startsWith("/api/")) return NextResponse.next();
// Already versioned โ pass through
const version = SUPPORTED_VERSIONS.find((v) =>
pathname.startsWith(`/api/${v}/`)
);
if (version) return NextResponse.next();
// Unversioned API request โ detect version from header or default
if (pathname.startsWith("/api/") && !pathname.startsWith("/api/_")) {
const requestedVersion =
req.headers.get("x-api-version") ?? DEFAULT_VERSION;
const resolvedVersion = SUPPORTED_VERSIONS.includes(requestedVersion)
? requestedVersion
: DEFAULT_VERSION;
const url = req.nextUrl.clone();
url.pathname = `/api/${resolvedVersion}${pathname.slice("/api".length)}`;
return NextResponse.rewrite(url);
}
return NextResponse.next();
}
Deprecation Warning in Webhook Payloads
// lib/webhooks/send-with-version.ts
interface WebhookPayloadV1 {
event: string;
created_at: string;
data: Record<string, unknown>;
}
interface WebhookPayloadV2 {
event: string;
createdAt: string;
apiVersion: string;
data: Record<string, unknown>;
_deprecation?: {
message: string;
sunsetDate: string;
migrationUrl: string;
};
}
export function buildWebhookPayload(
event: string,
data: Record<string, unknown>,
webhookVersion: "v1" | "v2"
): WebhookPayloadV1 | WebhookPayloadV2 {
if (webhookVersion === "v1") {
return {
event,
created_at: new Date().toISOString(),
data,
// Always include deprecation warning in v1 payloads
...(true && {
_deprecation: {
message:
"Webhook v1 is deprecated. Please migrate to v2 by 2028-03-31.",
sunsetDate: "2028-03-31",
migrationUrl: "https://docs.acme.com/webhooks/migration",
},
} as any),
};
}
return {
event,
createdAt: new Date().toISOString(),
apiVersion: "v2",
data,
};
}
Deprecation Monitoring
Track which API versions consumers are using so you know who to notify before sunset:
// middleware/api-version-tracking.ts
import { prisma } from "@/lib/prisma";
import { redis } from "@/lib/redis";
export async function trackApiVersionUsage(
apiKeyId: string,
version: string,
endpoint: string
): Promise<void> {
// Lightweight: increment Redis counter per key+version
// Avoid hitting DB on every request
const key = `api_version:${version}:${apiKeyId}:${new Date().toISOString().slice(0, 10)}`;
await redis.incr(key);
await redis.expire(key, 60 * 60 * 24 * 90); // Keep 90 days
}
// Daily job: identify consumers still on deprecated versions
export async function getDeprecatedVersionConsumers(
version: string,
since = 7
): Promise<Array<{ apiKeyId: string; requestCount: number }>> {
// Scan Redis keys for the version
const pattern = `api_version:${version}:*`;
const keys = await redis.keys(pattern);
const results = new Map<string, number>();
for (const key of keys) {
const parts = key.split(":");
const apiKeyId = parts[2];
const count = await redis.get(key);
results.set(apiKeyId, (results.get(apiKeyId) ?? 0) + Number(count ?? 0));
}
return Array.from(results.entries())
.map(([apiKeyId, requestCount]) => ({ apiKeyId, requestCount }))
.sort((a, b) => b.requestCount - a.requestCount);
}
Deprecation Communication Timeline
| Milestone | Action | Timeline |
|---|---|---|
| v2 launches | Announce deprecation, update docs, add Sunset/Deprecation headers | Day 0 |
| 3 months | Email all v1 users with migration guide | +90 days |
| 6 months | Warning banners in developer dashboard | +180 days |
| 9 months | Email non-migrated users individually | +270 days |
| 11 months | Final email + disable non-critical v1 endpoints | +330 days |
| 12 months | Sunset: return 410 Gone for v1 | +365 days |
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| URL versioning + shared service layer | 1 dev | 3โ5 days | $800โ1,500 |
| + Deprecation headers + tracking | 1 dev | 1 week | $1,500โ3,000 |
| Full version migration (v1 โ v2 + comms) | 1โ2 devs | 3โ5 weeks | $6,000โ15,000 |
| Header-based negotiation (complex routing) | 1โ2 devs | 2โ3 weeks | $4,000โ9,000 |
See Also
- SaaS API Rate Limiting with Redis
- SaaS Webhook System with Delivery Guarantees
- TypeScript Utility Types and Branded Types
- Next.js Middleware Authentication Patterns
- SaaS Audit Logging and Compliance
Working With Viprasol
A versioning strategy you design upfront costs a few days. A versioning strategy you retrofit after breaking integrations costs months. Our team designs API versioning architectures with a shared service layer (so logic isn't duplicated), deprecation headers (so consumers know what's coming), and monitoring (so you know who to contact before sunset).
What we deliver:
- URL path versioning structure in Next.js App Router
- Shared service layer so business logic isn't version-coupled
- Deprecation headers: Sunset, Deprecation, Link (RFC 8594)
- API version usage tracking per consumer
- Migration documentation templates
Talk to our team about your API versioning strategy โ
Or explore our SaaS development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow โ automating repetitive tasks, qualifying leads, and responding across every channel your customers use.