Skip to content
Zod at the Trust Boundary: Validate Where Data Enters
Milos KnezevicMilos Knezevic20 Oct 2026 · 12 min read

Zod at the Trust Boundary: Validate Where Data Enters

TypeScript types vanish at runtime. Map your app's trust boundaries and let Zod defend them — schema-first types, env checks at boot, and when to skip it.

A request body is whatever the network says it is

Every TypeScript type you write is erased before your code runs. The compiler checks your program against the types, emits plain JavaScript, and throws the types away. An interface describing your request body is a promise between you and the compiler — and the compiler is not present in production. Nothing at runtime checks that promise.

This is easy to know and easy to forget, because casting feels like checking. Writing await req.json() as CreateCommentInput compiles cleanly, autocompletes beautifully, and verifies nothing. req.json() returns whatever the sender encoded: a number where you expect a string, a missing field, an extra field, a five-megabyte string headed for a column you sized at 200 characters. The cast does not inspect a single byte. It just tells the compiler to stop worrying.

The failure mode is sneaky because your own frontend is well-behaved. Your form sends exactly the fields your API expects, so the endpoint works for months. Then someone calls it directly — a script, a mobile client built later, an attacker — and malformed data walks straight into your database. The first time you debug a crash like this, you will assume the bug is in your handler. It is not. The bug is that there was no boundary.

Zod's job is to be that boundary: a runtime check that produces a compile-time type. It gets filed under form libraries because that is where most people meet it, but forms are one boundary out of many — and not even the most dangerous one.

Map your boundaries before you write a schema

A trust boundary is any line in your system where data crosses from code you wrote and typed into data someone else produced. Inside the boundary, TypeScript's guarantees hold, because every value was constructed by code the compiler checked. Outside, all bets are off. Before writing any schema, it is worth listing where those lines actually run in a production Next.js app:

  • API route bodies — every POST, PUT, and PATCH handler that reads JSON from the request
  • Form submissions and server actions — FormData is stringly typed and arrives from the client
  • URL search params — ?page=2 is user input, and users edit URLs
  • Environment variables — process.env is a bag of string | undefined assembled by your hosting platform
  • Third-party API responses — the payment provider's documentation is not a runtime guarantee
  • Webhook payloads — a signature proves who sent it, not what shape it has
  • localStorage and cookies — anything persisted in the browser can be edited in devtools

Two of these surprise people. Environment variables feel like configuration rather than input, but they are strings assembled outside your codebase by a dashboard, a CI pipeline, or a teammate's memory of what the variable was called. And third-party responses feel safe because there is documentation — but documentation describes intent, and real APIs change, degrade, and return error shapes that look nothing like the happy path.

Webhooks deserve a special mention because verification and validation get conflated. Verifying a signature — the way Stripe webhook verification proves a payload genuinely came from Stripe — says nothing about whether the event shape matches what your handler assumes. Providers add fields, deprecate fields, and deliver event types you were not thinking about when you wrote the handler. Verify the signature first, then parse the payload — two checks, two different threats.

What sits inside the boundary? Your own database, mostly: Prisma generates types from your schema, and the values in those columns were written by your validated code. Your own function calls between modules. The map also does double duty — the same entry points that need validation are the ones that need protection from abuse, which is why rate limiting in Next.js starts from exactly the same list.

Parse, don't validate — and let the schema carry the type

The phrase parse, don't validate comes from a now-famous essay by Alexis King, and the distinction is worth internalising. Validation checks a value and hands you back a boolean — then you carry on with the same untyped value, having thrown the knowledge away. Parsing transforms an unknown value into a typed one you keep. Zod parses: unknown data goes in, and what comes out is either a value with a real TypeScript type or a structured error.

app/api/comments/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";

const createCommentSchema = z.object({
  postSlug: z.string().min(1).max(200),
  body: z.string().trim().min(1).max(2000),
});

type CreateComment = z.infer<typeof createCommentSchema>;

export async function POST(req: Request) {
  const json = await req.json().catch(() => null);
  const result = createCommentSchema.safeParse(json);

  if (!result.success) {
    return NextResponse.json(
      { error: { code: "VALIDATION_ERROR", fields: z.flattenError(result.error).fieldErrors } },
      { status: 422 },
    );
  }

  const comment: CreateComment = result.data; // fully typed, actually checked
  return NextResponse.json({ data: comment }, { status: 201 });
}

Notice what z.infer does. The schema is the single source of truth: the runtime check and the static type are derived from the same declaration, so they cannot drift apart. If you have ever maintained an interface in one file and a validator in another, you know they diverge within a month. With inference, renaming a field in the schema breaks every usage at compile time and rejects old payloads at runtime, in one edit.

The error branch deserves as much design as the happy path. z.flattenError collapses Zod's issue list into a map from field name to human-readable messages — exactly the shape a client needs to highlight the right input. What you must not do is return the raw error object, a stringified exception, or anything from a catch block. Zod's issues describe your expected structure in detail, and leaked stack traces name your libraries and file paths; both hand an attacker a free map of your API. Field-level messages for the sender, full details for your server logs, nothing in between.

safeParse and parse split the work by what failure means. safeParse returns a result object and never throws — use it wherever bad input is expected and survivable, which is every endpoint that faces users; you turn the error into a 422 and move on. parse throws — use it where invalid data means the program cannot meaningfully continue, like configuration at startup or an invariant that should be impossible. The rule of thumb: safeParse when the sender might be wrong, parse when you are.

The third habit is transforming while you validate. Boundaries are where data should be normalised, so the rest of your code never has to wonder. URL search params are the clearest case, because everything arrives as a string:

lib/schemas/list-params.ts
import { z } from "zod";

export const listParamsSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  sort: z.enum(["newest", "oldest"]).default("newest"),
});

// "?page=3&limit=abc" -> page is 3, limit gets a clean error
// "?" alone           -> { page: 1, limit: 20, sort: "newest" }

z.coerce turns "2" into 2 before checking it, default fills the gaps, and max(100) on limit quietly prevents the classic ?limit=100000 request that takes your database out for everyone. Trim strings, lowercase emails, coerce numbers, default the optional — all at the edge, once.

tip

Normalise at the boundary, not in the handler. If emails are trimmed and lowercased inside the schema, no code deeper in the app ever needs to remember to do it — and no duplicate account gets created because of a trailing space.

Validate process.env at boot, not at 3 a.m.

Environment variables deserve their own section because they fail in the worst possible way: late. A missing DATABASE_URL does not crash your deploy. It waits for the first request that needs the database, then fails somewhere deep inside a connection library, with a stack trace that mentions sockets instead of configuration. If that first request happens at 3 a.m., the error has had hours to compound before anyone reads it.

The fix is to validate process.env once, at module load, and crash immediately if anything is missing or malformed. This is the one boundary where parse is right and safeParse is wrong — a loud failure at boot, in CI, or on a deploy preview is the entire point.

lib/env.ts
import { z } from "zod";

const envSchema = z.object({
  DATABASE_URL: z.url(),
  AUTH_SECRET: z.string().min(32),
  RESEND_API_KEY: z.string().startsWith("re_"),
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
});

export const env = envSchema.parse(process.env);
// Import { env } everywhere instead of touching process.env.
// A bad deploy now fails at boot, not at request time.

From here on, import env from this file and never touch process.env directly. You get autocompletion on variable names, a guaranteed string instead of string | undefined, and one file that documents every variable the app needs. The min(32) on AUTH_SECRET is not decoration either — a weak or truncated secret gets caught before it ever signs a session.

One Next.js-specific caveat: variables prefixed NEXT_PUBLIC_ are inlined into the client bundle at build time, so they need checking at build rather than in process.env on the client. For everything server-side, module-load validation is exactly right.

One schema, both sides of the wire

There is one more place a schema earns its keep: the browser. A good signup form validates as the user types — wrong email format, and a message appears under the field before they ever submit. That is client-side validation, and it is a UX feature: instant feedback, no round trip, no lost form state. It is not a security feature, because nothing forces a sender to use your form. The same request can arrive from curl with your JavaScript stripped away entirely.

warning

Client-side validation is UX; server-side validation is security. The first makes honest users faster, the second stops dishonest ones. Skip the first and you ship a clunky form. Skip the second and you ship a vulnerability.

So you validate twice — and the trap is maintaining the rules twice, once in the form library and once on the server, because two copies always drift. The fix is the pattern this whole article has been building toward: the schema is a plain TypeScript value, so put it in a shared module and import it from both sides.

lib/schemas/subscribe.ts
import { z } from "zod";

export const subscribeSchema = z.object({
  email: z.email("Enter a valid email address").max(254),
  name: z.string().trim().min(1, "Tell us what to call you").max(100),
});

export type SubscribeInput = z.infer<typeof subscribeSchema>;

On the client, React Hook Form accepts the schema through its resolver — useForm with zodResolver(subscribeSchema) — and renders field-level messages from exactly the rules the server will enforce. On the server, the action calls subscribeSchema.safeParse on the incoming data as if the client validation never happened, because as far as security is concerned, it did not. One schema, two jobs: in the browser it is a courtesy, on the server it is the law. And because the messages live in the schema, the wording the user sees inline matches what the server returns on a bypassed submit.

When the contract evolves, derive — don't loosen

Sharing cuts the other way, though, and here is the trap nearly every codebase falls into. You write one postSchema and reuse it everywhere: the create endpoint, the update endpoint, the response type. It works on day one because the shapes happen to match. Then update needs to accept partial data, so someone adds .optional() to title — and now the create endpoint accepts posts without titles. Then the response needs id and createdAt, so they get added to the shared schema — and now create demands the client send a server-generated id. Each fix for one consumer quietly breaks the contract for another. Zod is built for exactly this: declare the common core once, then derive each contract from it.

lib/schemas/post.ts
import { z } from "zod";

const postCore = z.object({
  title: z.string().trim().min(1).max(200),
  slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
  content: z.string().min(1),
  published: z.boolean().default(false),
});

export const createPostSchema = postCore;
export const updatePostSchema = postCore.partial();
export const postResponseSchema = postCore.extend({
  id: z.string(),
  createdAt: z.iso.datetime(),
});

export type CreatePost = z.infer<typeof createPostSchema>;
export type UpdatePost = z.infer<typeof updatePostSchema>;

partial, pick, omit, and extend keep the core in one place while each contract stays honest. The same discipline covers versioning. A deployed schema with external consumers is a published contract, so do not edit it in place: additive changes are the cheap kind — a new field declared .optional() or given a .default() keeps every existing sender valid — while breaking changes get a v2 schema alongside v1, with /api/v2 routed to it. For webhook-style payloads that carry a version field, z.discriminatedUnion on that field lets each version parse against its own shape. The schema file quietly becomes the changelog your API never had.

Where Zod is overkill, and why performance is not the issue

Now the honest part. Once Zod clicks, the temptation is to validate everything everywhere — every function argument, every return value, every module edge. Resist it. A call from your service layer into your own utility function is not a trust boundary; TypeScript already guarantees that contract, because both sides were compiled together. Parsing it again at runtime adds noise, a second source of truth, and a false sense that validation is happening where it matters. Worse, it blurs your map: if everything is a boundary, nothing is.

The same goes for data coming out of Prisma. The client generates types from your schema, and the rows were written by your own validated code — parsing them again on every read is cargo cult. The one exception is JSON columns: a Json field is an untyped blob that even Prisma cannot vouch for, and reading one is a genuine boundary that deserves a schema.

As for the performance question that comes up in every team discussion: parsing a request body with Zod costs on the order of microseconds; the network round trip that delivered the body costs tens to hundreds of milliseconds, and the database query that follows costs more again. Validation at the boundary is a rounding error in any handler that does real work. The cost worth watching is a different one: bundle size, if you import a large server schema into client components. Keep heavyweight schemas on the server, and share with the browser only the ones your forms genuinely need.

info

If a profiler ever does show schema parsing as a hotspot, it will be a large schema in a tight loop over thousands of items — re-parsing internal data the previous section already told you not to parse. At the boundary, the cost is noise.

Draw the map for the app you have open right now

Validation at trust boundaries is a single line in the production-grade code checklist — this article is that line unpacked. The order of operations for an existing app: write lib/env.ts first, because fifteen lines there convert a whole category of 3 a.m. configuration failures into boot-time failures. Then list every route handler and server action that reads a body, and give each one a schema with an inferred type. Then search params. Then any webhook or third-party response you consume. Forms last — moving their rules into shared schemas is a refactor you can do one form at a time.

You will know the habit has settled when you stop thinking about Zod at all and start seeing the boundaries themselves — when a new endpoint feels naked without its schema the way it would feel naked without auth. That instinct, not the API, is the thing worth practising on every project you ship.