Web Architecture

Five API Design Patterns I Use on Every Next.js Project

After years of building Next.js backends for production apps, I've settled on a handful of patterns that prevent the most common and expensive mistakes. These aren't opinions — they're the result of debugging production failures.

March 4, 20269 min read
Next.jsAPI designweb architectureTypeScriptREST

These patterns didn't come from reading documentation. They came from debugging production failures at 2am, reviewing incident reports, and doing post-mortems on API issues that turned out to be embarrassingly preventable.

Every one of these patterns exists because I've seen — or caused — the problem it prevents. I've stopped arguing about whether they're necessary. I just implement them from the start.

1. Always Validate at the Boundary with Zod

The boundary is the edge of your system: the point where untrusted data enters. In a Next.js API route, that's the request body, query parameters, and route params. Everything beyond that boundary should be typed and validated — not assumed.

The pattern is simple: parse the request with a Zod schema before touching the data. If parsing fails, return a 400 immediately. Never let unvalidated data travel deeper into your application.

import { z } from "zod";

const CreateProjectSchema = z.object({
  name: z.string().min(1).max(100),
  teamId: z.string().uuid(),
  visibility: z.enum(["public", "private"]),
});

export async function POST(req: Request) {
  const body = await req.json();
  const result = CreateProjectSchema.safeParse(body);

  if (!result.success) {
    return Response.json(
      { error: "Invalid request", issues: result.error.issues },
      { status: 400 }
    );
  }

  const { name, teamId, visibility } = result.data;
  // result.data is fully typed and validated
}

What this prevents: type confusion errors where a field you expected to be a UUID arrives as an integer and gets passed to a database query unmodified; injection vectors where unchecked string length or format assumptions are exploited; the entire class of "we trusted the client" bugs.

I've seen a production application where a numeric field in a JSON body was silently coerced to NaN by a parseFloat call, stored in the database, and then caused a downstream financial calculation to produce null for affected records. The fix was a one-line Zod validator. The impact was a four-hour incident and manual reconciliation.

Use Zod on every route. No exceptions.

2. Consistent Error Response Shape Across All Routes

This sounds trivial. It's not.

When every API route returns errors in a different shape, every client has to handle errors differently. Your frontend ends up with error handling code that's a maze of if (error.message)... else if (error.error)... else if (error.errors[0]).... Your mobile client has the same problem. And when you're debugging a production issue at midnight, inconsistent error shapes mean you're also reading source code to figure out what shape to expect.

Define one error shape. Use it everywhere.

// lib/api-response.ts
export function apiError(
  message: string,
  status: number,
  code?: string,
  details?: unknown
) {
  return Response.json(
    {
      success: false,
      error: {
        message,
        code: code ?? "UNKNOWN_ERROR",
        details: details ?? null,
      },
    },
    { status }
  );
}

export function apiSuccess<T>(data: T, status = 200) {
  return Response.json({ success: true, data }, { status });
}

With this, every route in your application returns the same envelope. Validation errors, authorization errors, not-found errors, internal errors — same shape, different code values. Clients can write one error handler. Logs have consistent structure. Monitoring alerts can parse errors predictably.

The teams I've worked with that skip this pattern always regret it. Standardizing error shapes after the fact — across 40 routes, with a mobile client and a web client already in production — is painful enough that most teams just live with the inconsistency. Don't put yourself in that position.

3. Parse and Strip — Never Trust req.body Directly

Validation catches the wrong types. Stripping removes the fields you didn't ask for.

This pattern matters for two reasons. First, mass assignment vulnerabilities: if you pass a parsed request body directly to a database update operation, an attacker who adds { "isAdmin": true } to their request body might get lucky if your ORM maps that field. Second, clarity: when you explicitly declare what fields you're accepting, your code documents its own interface.

Zod handles this with .strip() behavior by default — any keys not in your schema are silently dropped. This is the correct behavior. Make it explicit:

const UpdateUserSchema = z.object({
  displayName: z.string().min(1).max(80).optional(),
  bio: z.string().max(500).optional(),
  // Note: no "role", no "isAdmin", no "planTier"
});

// result.data will ONLY contain displayName and bio
// even if the request body contained other fields
const { data } = UpdateUserSchema.parse(body);
await db.users.update({ where: { id: userId }, data });

This is also where TypeScript's structural typing can mislead you. An object that has { displayName, bio, isAdmin } is assignable to a type that only declares { displayName, bio } — TypeScript doesn't strip extra properties. Zod does. This distinction matters in security-critical contexts.

Parse what you need. Strip what you don't. The extra twenty seconds of writing the schema pays for itself the first time someone tries to escalate privileges through your update endpoint.

4. Request-Scoped Logging with a Correlation ID

When something goes wrong in production, the first question is: what happened? The second question is: which request caused it?

Without correlation IDs, your logs look like a blender. You have timestamps, you have error messages, but tracing a single user's request through server logs, database logs, and third-party API calls is nearly impossible when multiple requests are interleaved.

The pattern: generate a UUID for every incoming request. Attach it to every log line that request produces. Return it in the response headers so clients can reference it in support tickets.

// middleware.ts
import { NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";

export function middleware(request: Request) {
  const correlationId = request.headers.get("x-correlation-id") ?? uuidv4();
  const response = NextResponse.next();
  response.headers.set("x-correlation-id", correlationId);
  return response;
}
// In your route handlers, access via headers
const correlationId = req.headers.get("x-correlation-id");
logger.info("Processing payment", { correlationId, userId, amount });

When your error monitoring (Sentry, Datadog, whatever you're using) captures an exception, it captures it with the correlationId. When your database query logs fire, they carry the correlationId. You can pull every event from a single user's request across your entire stack in seconds.

I've worked on systems without this. Finding the root cause of a production issue that involves three services, a database timeout, and a retry loop is — without correlation IDs — an exercise in forensic archaeology. With them, it's a fifteen-second log query.

The implementation cost is an afternoon. The operational value is ongoing.

5. Rate Limiting as Middleware, Not Per-Route

Rate limiting is one of those things teams do eventually, usually after a scraping incident or a credential stuffing attack or a runaway client hammering an endpoint. The question is whether you implement it correctly.

The wrong pattern: adding rate limiting logic inside individual route handlers when you remember to. This means some routes are protected, some aren't, the limits are inconsistent, and the implementation is duplicated across the codebase.

The right pattern: rate limiting at the middleware layer, applied uniformly, with differentiated limits by route type rather than per-route implementation.

// middleware.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(60, "1 m"), // 60 requests per minute
});

export async function middleware(request: NextRequest) {
  const ip = request.ip ?? "anonymous";
  const { success, limit, remaining } = await ratelimit.limit(ip);

  if (!success) {
    return new NextResponse("Too Many Requests", {
      status: 429,
      headers: {
        "X-RateLimit-Limit": limit.toString(),
        "X-RateLimit-Remaining": remaining.toString(),
        "Retry-After": "60",
      },
    });
  }
}

For SaaS applications, I typically define at least three rate limit tiers: unauthenticated requests (tightest), authenticated free-plan users, and authenticated paid users. These are middleware configurations, not per-route logic. New routes inherit the correct tier automatically.

What this prevents is straightforward: scraping, credential stuffing, runaway API clients, and denial-of-service from misbehaving integrations. What's less obvious is what it prevents on your infrastructure bill — a single misconfigured client hitting an expensive database query 10,000 times in a minute can produce real cost on a consumption-billed database. Rate limiting is also infrastructure cost protection.

Implement it before you launch. Adjusting limits is a configuration change. Retrofitting rate limiting onto a live API that clients are already integrated against is a coordination problem.


Why These Patterns Together

Each of these patterns solves a specific failure mode. But they also compose: a validated, stripped request body gets logged with a correlation ID, the log shows a clean error shape if validation fails, and the whole exchange is protected by rate limiting before it ever reaches your validation logic.

The result is an API layer that fails predictably, logs usefully, and resists abuse by default. That's not over-engineering — it's the baseline for production software.

Start with these five. The next layer of patterns can wait until you've shipped.

Apply

If this maps to a problem you're working on.

I work with $1M–$20M ARR founders whose digital investment isn't producing the return it should. Applications reviewed personally within 48 hours.

2 Diagnostic slots / month · 2–3 full engagements / quarter · 48h review