Back to Blog

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.

Viprasol Tech Team
March 31, 2027
13 min read

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

StrategyURL exampleProsCons
URL path/v1/usersEasy to test, bookmark, log"Unclean" URLs, not REST-pure
Query parameter/users?version=1Easy to addEasy to miss, cache-unfriendly
Accept headerAccept: application/vnd.acme.v1+jsonREST-correctHard to test in browser, complex routing
Custom headerX-API-Version: 1SimpleNot 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

MilestoneActionTimeline
v2 launchesAnnounce deprecation, update docs, add Sunset/Deprecation headersDay 0
3 monthsEmail all v1 users with migration guide+90 days
6 monthsWarning banners in developer dashboard+180 days
9 monthsEmail non-migrated users individually+270 days
11 monthsFinal email + disable non-critical v1 endpoints+330 days
12 monthsSunset: return 410 Gone for v1+365 days

Cost and Timeline Estimates

ScopeTeamTimelineCost Range
URL versioning + shared service layer1 dev3โ€“5 days$800โ€“1,500
+ Deprecation headers + tracking1 dev1 week$1,500โ€“3,000
Full version migration (v1 โ†’ v2 + comms)1โ€“2 devs3โ€“5 weeks$6,000โ€“15,000
Header-based negotiation (complex routing)1โ€“2 devs2โ€“3 weeks$4,000โ€“9,000

See Also


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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

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.