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.
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:
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)));
});
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.
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:
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.
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");
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:
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);
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
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:
- Create a
v2router that changes an existing endpoint's response shape, while keepingv1working unmodified for existing callers; addDeprecation/Sunsetheaders to thev1response. - 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.
- Add
zod-to-openapiand confirm the generated Swagger UI accurately reflects your endpoints, including validation constraints and error responses. - Add a Redis-backed rate limit and confirm a client exceeding it receives a
429.
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?
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?
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?
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.