Week 16: API Design at Scale — Versioning, Pagination & OpenAPI

Week 3 taught you to build a correct, validated endpoint. It didn't teach you what happens six months later when that endpoint needs to change shape and other clients already depend on the old one. This week is about designing an API surface that can evolve: versioning that doesn't break existing clients, pagination that scales past a few thousand rows, and turning your Zod schemas into documentation a consumer can actually rely on.

Module 13 of 22 Week 16 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Choose and implement an API versioning strategy without breaking existing clients
  • Implement cursor-based pagination and consistent filtering/sorting conventions
  • Generate OpenAPI docs from your Zod schemas and apply per-client rate limiting

1. API Versioning & Deprecation

The moment an API has consumers you don't fully control, a "breaking change" isn't just a code change — it's a change that breaks someone else's running application. Express's router makes URI versioning straightforward: mount differently-versioned routers under different prefixes, each with its own Zod schemas.

app.ts — mounting two versions side by side
import { tasksRouterV1 } from "./routes/tasks.v1.js";
import { tasksRouterV2 } from "./routes/tasks.v2.js";

app.use("/api/v1/tasks", tasksRouterV1);
app.use("/api/v2/tasks", tasksRouterV2);

Both routers can share the same service layer and Prisma models underneath — only the Zod request/response schemas and route handlers need to differ between versions, so v2's new shape doesn't require duplicating business logic.

Whichever strategy you pick, deprecation needs a signal, not a surprise removal. A Deprecation and Sunset HTTP header (RFC 8594) on the old version's responses gives clients a machine-readable warning and a concrete date, well before the endpoint actually disappears:

signaling deprecation on v1
router.get("/:id", async (req, res) => {
  res.set("Deprecation", "true");
  res.set("Sunset", "Wed, 01 Apr 2026 00:00:00 GMT");
  res.set("Link", '</api/v2/tasks>; rel="successor-version"');
  res.json(toResponseV1(await taskService.getTask(req.params.id)));
});
A breaking change is defined by the consumer, not the producer

Adding a new optional field to a Zod response schema is safe; renaming an existing field, changing its type, or making an optional field required are all breaking, even if the diff looks small. Before shipping any API change, ask specifically whether an existing client's current parsing code would still work unmodified — that's the real test, not whether the change feels minor to write.

2. Pagination, Filtering & Sorting

Prisma's skip/take gives you offset-based pagination almost for free, and it's the right default for most admin screens and small datasets. Its real weakness at scale: a large skip forces the database to scan and discard every row before it, so deep pages against a large table get progressively slower — and rows inserted or deleted between page requests can shift offsets, silently skipping or duplicating rows.

offset pagination — the simple default
router.get("/tasks", async (req, res) => {
  const page = Number(req.query.page ?? 1);
  const pageSize = 20;

  const tasks = await prisma.task.findMany({
    skip: (page - 1) * pageSize,
    take: pageSize,
    orderBy: { createdAt: "desc" },
  });
  res.json(tasks);
});

Cursor-based (keyset) pagination solves both problems by paginating from a stable reference point instead of a row count. Prisma has first-class support for this via cursor:

cursor-based pagination
router.get("/tasks", async (req, res) => {
  const cursor = req.query.cursor as string | undefined;

  const tasks = await prisma.task.findMany({
    take: 20,
    ...(cursor && { skip: 1, cursor: { id: cursor } }),   // skip the cursor row itself
    orderBy: { id: "desc" },
  });

  const nextCursor = tasks.length === 20 ? tasks[tasks.length - 1].id : null;
  res.json({ items: tasks, nextCursor });
});

This is why most large-scale public APIs (Stripe, GitHub, Slack) use cursors rather than offsets — the query stays roughly constant-time regardless of how deep into the dataset a client has paginated, and rows inserted during pagination don't shift already-fetched results.

Standardize filtering and sorting the same way across every list endpoint — ?sort=createdAt:desc&status=open, applied identically everywhere — rather than each endpoint inventing its own query parameter shape.

3. OpenAPI Documentation & Rate Limiting

You already validate every request with Zod (Week 3). @asteasolutions/zod-to-openapi extends the same schemas to generate an OpenAPI 3 spec directly from them — one source of truth for both runtime validation and documentation, so they can't drift apart the way a hand-maintained separate spec would.

schemas/task.ts — a schema doing double duty
import { z } from "zod";
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";

extendZodWithOpenApi(z);

export const TaskResponseSchema = z.object({
  id: z.string().openapi({ example: "task_abc123" }),
  title: z.string().openapi({ example: "Ship the Q3 report" }),
  status: z.enum(["open", "in_progress", "done"]),
}).openapi("Task");
generating the spec & serving Swagger UI
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
import swaggerUi from "swagger-ui-express";

const generator = new OpenApiGeneratorV3(registry.definitions);
const openApiDocument = generator.generateDocument({
  openapi: "3.0.0",
  info: { title: "Task API", version: "1.0.0" },
});

app.use("/docs", swaggerUi.serve, swaggerUi.setup(openApiDocument));

Once an API has external, potentially untrusted consumers, rate limiting protects it from a single client — buggy or malicious — consuming a disproportionate share of capacity. express-rate-limit backed by Redis (already in your stack since Week 11) is the standard fit:

a per-client rate limit backed by Redis
import rateLimit from "express-rate-limit";
import { RedisStore } from "rate-limit-redis";

const limiter = rateLimit({
  windowMs: 60_000,
  limit: 100,
  store: new RedisStore({ sendCommand: (...args) => redis.call(...args) }),
  keyGenerator: (req) => req.headers["x-api-key"] as string,
});

app.use("/api", limiter);
Always back a rate limiter with Redis, never in-process memory

The default in-memory store resets independently on every clustered worker (Week 14's PM2/cluster module) and every replica behind a load balancer — a client could get far more than the intended limit split across workers and replicas. A shared Redis store gives every process a single, consistent view of each client's usage, which is what actually enforces the limit you configured.

4. Hands-on Exercise

Hands-on

Version an endpoint, add cursor pagination, and document and rate-limit the API

Apply all three practices to the task service from earlier weeks.

Requirements:

  1. Create a v2 router that changes an existing endpoint's response shape, while keeping v1 working unmodified for existing callers; add Deprecation/Sunset headers to the v1 response.
  2. Replace offset pagination on your task list endpoint with cursor-based pagination, and confirm query performance stays flat when paginating deep into a table seeded with several thousand rows.
  3. Add zod-to-openapi and confirm the generated Swagger UI accurately reflects your endpoints, including validation constraints and error responses.
  4. Add a Redis-backed rate limit and confirm a client exceeding it receives a 429.
Hint

Seed at least 10,000 test rows before comparing offset vs. cursor pagination performance — the difference is invisible on a table with 50 rows and becomes obvious once a large skip actually has something expensive to scan past.

5. Knowledge Check

Three quick questions. Expand each to check your answer.

Q1

Why can two versioned routers safely share the same service layer and Prisma models?

The business logic and persistence layer don't change between API versions — only the request/response shape presented to clients differs. Keeping the underlying service and Prisma models shared avoids duplicating logic across versions; only the Zod schemas and thin route handlers that translate between the shared internal representation and each version's public contract need to differ.

Q2

Why does cursor-based pagination stay roughly constant-time while offset pagination gets slower on deeper pages?

A large skip still has to scan and discard every row before the requested offset, so the cost grows with how deep into the table the client has paginated. A cursor query filters directly on an indexed column with a WHERE-style condition, so the database can jump straight to the right starting point using the index regardless of how many rows came before it.

Q3

Why does an in-memory rate limit store break down once a Node API runs multiple clustered workers or replicas?

Each worker process (and each replica) holds its own independent in-memory count, with no shared state between them. A client's requests get distributed across workers and replicas, so their actual consumption is split across several independently-tracked limits instead of one shared one — effectively multiplying their true rate limit, unless the count lives in a shared store like Redis instead.