Tutorial13 min read · Updated September 10, 2026

How to Add Zod to Next.js (2026 Guide)

TypeScript types are erased at build time, so every request body, form submission, search param and webhook payload that arrives at your app is `unknown` no matter how it is annotated. Zod closes that gap: one schema gives you a runtime check and a static type from the same declaration. This guide covers Zod 4 in the App Router — where to parse, how to surface errors, and the patterns that stop a schema becoming a second copy of your types.

What Zod is, and where it belongs

Zod is a schema library with one defining trick: a schema is simultaneously a runtime validator and a static type. Write `const Signup = z.object({ email: z.email(), name: z.string().min(1) })` and you get both `Signup.parse(input)`, which checks the value at runtime, and `z.infer<typeof Signup>`, which is the TypeScript type `{ email: string; name: string }`. The two cannot drift, because there is only one declaration.

That matters because TypeScript annotations do not survive compilation. A route handler that writes `const body = (await req.json()) as SignupBody` has asserted a type, not checked one; the cast is a promise to the compiler that nothing enforces. The value arriving over the wire is whatever the caller sent, and the first place the lie surfaces is usually a database write with a null in a non-null column.

So the rule for where Zod goes is: at every trust boundary, and nowhere else. Request bodies in route handlers, `FormData` in Server Actions, search params, environment variables at boot, webhook payloads, and responses from third-party APIs all cross a boundary. Calls between your own already-typed functions do not, and wrapping those in schemas buys you nothing but overhead. If the shape of the data is not settled yet, draft a structured build prompt first — the fields you list there are the schema you are about to write.

Step 1 — Install and import

`npm i zod`, as a runtime dependency rather than a dev one — the validators execute in production, so a `--save-dev` install will build locally and fail on the server. Then `import { z } from "zod"`. There is no configuration file, no initialisation, and no provider to mount.

The package ships several entry points and it is worth knowing which is which. `zod` is the current API. `zod/v4` exists because version 4 was first published inside the version 3 package under that subpath; with a 4.x install the root and the subpath are the same object, which is why a codebase that migrated during the transition — this one imports `zod/v4` in its API routes — needs no rewrite. `zod/v3` still ships the old API alongside, for a gradual migration. `zod/mini` is the same validation engine behind a functional, tree-shakeable API: method chains become explicit checks, so `z.string().min(3)` is written `z.string().check(z.minLength(3))`. Reach for it only when bundle size is a measured problem, because the ergonomics are noticeably worse.

One practical note about where schemas live. The package declares itself side-effect free and bundles cleanly into a Client Component, which is exactly what you want when the same schema validates a form on both sides of the wire. But a schema that references server-only values — an environment variable in a `.default()`, say — will drag those into the client bundle, so keep those in a module the client never imports.

Step 2 — Write the schema

Primitives compose into objects and the chained methods narrow them: `z.string().min(1).max(100)`, `z.number().int().positive()`, `z.boolean()`, `z.enum(["draft", "published"])`, `z.array(z.string())`. Version 4 promoted the string formats to top-level constructors, so it is `z.email()`, `z.url()` and `z.uuid()` now. The old `z.string().email()` still runs, but it carries an `@deprecated` annotation in the shipped type definitions, and your editor will say so.

Objects strip unknown keys by default: parse `{ id: "1", name: "n", extra: true }` against a schema without an `extra` field and the parsed value has two keys, not three. That default is the safe one — it means a client cannot smuggle an unexpected field into a database write by adding it to the payload. When you want the opposite, `z.strictObject({...})` rejects unknown keys with an error, and `z.looseObject({...})` passes them through.

Schemas compose rather than repeat. `.pick({ id: true })`, `.omit({ createdAt: true })`, `.partial()` and `.extend({ ... })` all derive a new schema from an existing one, which is how you get a `CreateProject` and an `UpdateProject` that cannot fall out of step. For a payload whose shape depends on one of its own fields, `z.discriminatedUnion("kind", [ ... ])` is both faster and clearer than a plain union: it reads the discriminant first and reports the failure at `path: ["kind"]` instead of dumping every branch's errors.

URL segments deserve a schema of their own rather than a bare `z.string()`. A regex such as `z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { error: "Lowercase letters, digits and single hyphens only" })` encodes the rule once, at the boundary, instead of leaving each write path to normalise its own. Decide the normalisation rules before you encode them — run a few real titles through a slug generator and see what they collapse to, including the ones with punctuation and accents.

Step 3 — Parse at the trust boundary

Two entry points, and the choice between them is about control flow. `.parse(value)` returns the typed value or throws a `ZodError`. `.safeParse(value)` never throws: it returns `{ success: true, data }` or `{ success: false, error }`. In a route handler you almost always want `safeParse`, because a validation failure is a 400 you are choosing to return, not an exception you are handling.

A route handler in full: `const parsed = Body.safeParse(await req.json()); if (!parsed.success) return NextResponse.json({ error: "Invalid request", issues: z.treeifyError(parsed.error) }, { status: 400 }); const { name } = parsed.data`. After that line `parsed.data` is typed, narrowed, and stripped of anything the schema did not name. Wrap the `await req.json()` itself in a `try`, because a body that is not valid JSON throws before Zod ever sees it.

Server Actions need one extra step, because `FormData` values are strings — or `File`s — and never numbers or booleans. `Object.fromEntries(formData)` gives you a plain object, and `z.coerce` handles the conversion: `z.object({ title: z.string().min(1), count: z.coerce.number().int().min(0) })` parses `{ title: "x", count: "3" }` into `{ title: "x", count: 3 }`. Checkboxes are the exception worth remembering — an unchecked box is absent from the `FormData` entirely rather than present and false, so model it as `z.coerce.boolean().default(false)` or read it explicitly.

Environment variables are the other boundary people skip, and the cheapest one to fix: a module that exports `export const env = z.object({ DATABASE_URL: z.url(), STRIPE_SECRET_KEY: z.string().min(1) }).parse(process.env)` turns a missing key into a failure at boot with the variable's name in the message, instead of a null-reference somewhere in a checkout handler at the worst possible moment. Use `.parse` here, not `safeParse` — you want the throw. If you would rather have this wiring generated than typed out, describe the app and InBuild generates the Next.js code — a paid plan is required to generate.

Step 4 — Errors your UI can render

A `ZodError` carries an `issues` array, and each issue has a `code`, a `path` array locating it inside the input, and a `message`. That is the raw material, but it is the wrong shape for a form: a component that renders the error under the email field wants to look up `"email"`, not filter an array by path on every render.

Three helpers reshape it, and each suits a different consumer. `z.flattenError(error)` returns `{ formErrors, fieldErrors }`, where `fieldErrors` is keyed by top-level field name — the right shape for a flat form, and what you want to send back from a Server Action. `z.treeifyError(error)` returns a nested tree that mirrors the input, so errors inside arrays and nested objects stay attached to their position. `z.prettifyError(error)` returns a formatted multi-line string, which is what you want in a CLI, a log line, or a build-time environment check.

Messages are customised with the `error` parameter, which in version 4 replaced the older `message`, `required_error` and `invalid_type_error` options with a single one. Pass a string — `z.string({ error: "Name is required" })` — or a function that receives the issue and returns a string, which is how you write a message that quotes the offending value. Set these on the checks a user can actually trip; the defaults for internal fields are fine, and a schema where every line carries a hand-written message is a schema nobody updates.

Step 5 — Refinements, transforms and pipes

`.refine()` adds a rule the built-in checks cannot express, usually one spanning two fields. `z.object({ password: z.string(), confirm: z.string() }).refine((v) => v.password === v.confirm, { error: "Passwords must match", path: ["confirm"] })` is the canonical example, and the `path` option is the part people miss: without it the issue lands at the root of the object and no field in your form will render it.

`.transform()` changes the value on its way through, which is also where Zod stops being only a validator. A schema with a transform has two types — the input it accepts and the output it produces — and `z.input<typeof S>` and `z.output<typeof S>` name them separately. `z.infer` is an alias for the output type. This distinction is easy to ignore until a form component typed with `z.infer` refuses a value the schema would happily accept, and the fix is to type the form with `z.input` instead.

`.pipe()` feeds one schema's output into another, which is how you normalise before validating rather than after. This site's project API uses exactly that: `z.string().trim().pipe(z.string().min(1, "Name required").max(100))`. The order matters — trim first, then check the length — so a name of nothing but spaces is rejected rather than stored as an empty string. Writing it as `z.string().min(1).trim()` would check the untrimmed value and let that case through.

One sharp edge: a `.refine()` whose callback is async makes the whole schema async. Calling `.parse()` on it throws an internal async error rather than a validation error, and the message will not point at the refinement. Use `await schema.parseAsync(value)` or `await schema.safeParseAsync(value)`. That said, think before putting a database lookup inside a schema at all — a uniqueness check belongs next to the insert that depends on it, where you can handle the race, not in a validator that ran a moment earlier.

Inference, and the JSON Schema bridge

The payoff for defining data once is that the type follows everywhere. `type Signup = z.infer<typeof Signup>` is the type your Server Action takes, your component props use, and your database helper returns. The failure mode to watch for is the opposite habit: hand-writing an `interface` next to the schema and keeping the two in sync manually. That reintroduces exactly the drift the schema existed to prevent, and the compiler will not notice, because both are internally consistent.

Version 4 added `z.toJSONSchema(schema)`, which converts a Zod schema into a JSON Schema document. It is genuinely useful in two places: describing a tool or function call to an LLM, which wants JSON Schema rather than TypeScript, and publishing an OpenAPI document for an API you already validate. Annotate fields with `.meta({ description: "..." })` and the description carries through into the generated schema, so the same declaration documents the endpoint and enforces it.

Not every schema converts. A `.transform()` has no JSON Schema equivalent, and neither does a `.refine()` with an arbitrary predicate — the checks are TypeScript functions, not declarative constraints. Keep the schema you publish free of them and layer the extra rules on a derived schema, or you will find the generated document quietly missing the rule you most wanted documented.

Common mistakes

Validating only on the client. A schema that runs in the browser is a user-experience feature and nothing more. The request can be replayed with any body at all, so the server-side parse is the one that counts; the client-side one just saves a round trip. Share the schema, run it twice.

Using `.parse()` in a request handler. The throw escapes into Next.js's error boundary and the caller gets a 500 for what was a malformed request. `safeParse` and an explicit 400 is both more correct and easier to debug.

Trusting the annotation instead of the parse. `const body = (await req.json()) as Body` compiles, reads fine in review, and checks nothing. If a cast appears anywhere near an I/O boundary, that is the line to replace.

Forgetting that `FormData` is all strings. A `z.number()` against a form field fails every time, because the value is `"3"` and not `3`. `z.coerce.number()` is the fix, and an absent checkbox is a separate case from a false one.

Leaving `.refine()` without a `path`. The validation is correct, the error is invisible, and the form looks broken with no message anywhere near the field that caused it.

Keeping a parallel `interface`. Derive the type with `z.infer` instead. Two declarations of the same shape will diverge, and the one the compiler checks is not the one the runtime enforces.

How to do it

  1. 1

    Install Zod

    npm i zod as a runtime dependency, not a dev dependency. Import with import { z } from "zod". There is no config file and nothing to initialise.

  2. 2

    Define the schema

    z.object({ ... }) with the fields the boundary accepts. Use the top-level formats z.email(), z.url() and z.uuid() rather than the deprecated z.string().email().

  3. 3

    Derive the type

    type Signup = z.infer<typeof Signup>. Use it everywhere instead of hand-writing a matching interface, so the type and the runtime check cannot drift.

  4. 4

    Parse in the route handler

    const parsed = Body.safeParse(await req.json()). On failure return a 400 with z.treeifyError(parsed.error). Wrap req.json() in a try — invalid JSON throws before Zod runs.

  5. 5

    Parse in the Server Action

    Object.fromEntries(formData), then a schema using z.coerce.number() and z.coerce.boolean() for the fields that arrive as strings. Return z.flattenError(error).fieldErrors to the form.

  6. 6

    Validate the environment

    A module that exports z.object({ ... }).parse(process.env) at import time, so a missing variable fails at boot with its name in the message.

  7. 7

    Add cross-field rules last

    .refine((v) => v.password === v.confirm, { error: "Passwords must match", path: ["confirm"] }). Without path the issue lands at the object root and no field renders it.

Frequently asked questions

What changed between Zod 3 and Zod 4?

The changes you hit first are the string formats and the error option. Formats moved to the top level, so z.email(), z.url() and z.uuid() replace z.string().email() and friends, which are now marked deprecated. The message, required_error and invalid_type_error options were replaced by a single error option that takes a string or a function. Error formatting moved to the standalone helpers z.treeifyError, z.flattenError and z.prettifyError. Version 4 also added z.toJSONSchema and the tree-shakeable zod/mini entry point.

Should I import from "zod" or "zod/v4"?

With a 4.x install they are the same object — the subpath exists because version 4 was first published inside the version 3 package, so codebases that migrated early import it that way and do not need to change. Use plain "zod" in new code. The package also still ships zod/v3 for gradual migrations, and zod/mini for a smaller bundle.

Can I use the same schema on the client and the server?

Yes, and it is the main reason to keep schemas in their own module. The client-side parse gives you instant field-level feedback; the server-side parse is the one that actually protects anything, because a request can be sent with any body regardless of what the form did. Just keep server-only values out of those shared schemas, or the bundler will pull them into the client.

Does Zod slow down my API routes?

Not at a scale you will notice for request bodies and form submissions — the work is proportional to the size of the object being checked, and a request payload is small. Where it can matter is validating large arrays inside a hot loop, or re-parsing the same data at several layers of the same request. Parse once, at the boundary, and pass the typed result inward.

How do I validate a nested object or an array of objects?

Nest the schemas: z.object({ items: z.array(z.object({ sku: z.string(), qty: z.number().int().positive() })) }). Errors keep their position, so an issue on the second item has path ["items", 1, "qty"]. Use z.treeifyError rather than z.flattenError for these, because the flat form only keys by top-level field and will lose the index.

Zod or a TypeScript-only approach with type guards?

Hand-written type guards are a second implementation of the same shape, and nothing makes the compiler check that the guard matches the type it claims to narrow. Zod derives the type from the check, so they cannot disagree. The cost is a runtime dependency in your bundle, which is why the mini entry point exists. For anything that crosses a network boundary the trade is usually worth it.

Free tools used in this guide

No signup — each runs in the browser or as a single server-side fetch.

Ready to build?

See a real InBuild template rendered live, with the exact exported code, before you pick a plan — no account needed.

Or skip the tutorial: describe it and InBuild generates the Next.js code for you. A paid plan is required to generate.

Build this with InBuild

More Next.js guides