Full-Stack Engineering
Zod Validation in Next.js: From Form Inputs to API Responses
TL;DR
TypeScript catches type errors at compile time. Zod catches them at runtime — where the real damage happens. Every boundary in your application where data enters from the outside world needs runtime validation: form submissions, API request bodies, query parameters, environment variables, third-party API responses. I use Zod v4 on every Next.js project I build. This article covers every validation pattern I reach for, with real code from production apps. If you only take one thing away: validate at system boundaries, trust nothing that crosses the wire.
Why TypeScript Alone Isn't Enough
TypeScript gives you a contract at compile time. You define a type, the compiler enforces it across your codebase, and you get autocomplete and error squiggles in your editor. That is genuinely useful. But it disappears completely the moment your code runs.
Consider this:
type CreateOrderInput = {
productId: string;
quantity: number;
email: string;
};
export async function POST(request: Request) {
const body: CreateOrderInput = await request.json();
// TypeScript says body is CreateOrderInput
// But at runtime? It could be literally anything.
await db.order.create({ data: body });
}TypeScript does not generate runtime checks. The as keyword and type annotations are erased during compilation. When a user sends { "quantity": "not-a-number", "email": 12345 } to your endpoint, TypeScript has nothing to say about it. Your database call fails with a cryptic Prisma error, or worse — it succeeds and writes garbage data.
This is not a theoretical problem. I have seen production databases with null in non-nullable columns, negative quantities in order tables, and email fields containing JSON objects. All from endpoints that had perfect TypeScript types but zero runtime validation.
Zod bridges this gap. You define a schema once, get both the runtime validation and the TypeScript type from the same source of truth. No drift between your types and your validation logic. No duplicate definitions to keep in sync.
import { z } from "zod";
const CreateOrderSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
email: z.string().email(),
});
// Derive the type from the schema — single source of truth
type CreateOrderInput = z.infer<typeof CreateOrderSchema>;Now your type and your validation are the same thing. Change the schema, the type updates automatically. That is the foundation everything else builds on.
Zod Basics
If you have not used Zod before, here is the mental model: you build a schema that describes what valid data looks like, then you parse untrusted data through it. If the data matches, you get a typed result. If it does not, you get structured errors that tell you exactly what went wrong.
Install Zod v4:
npm install zodThe primitive validators cover every base case:
import { z } from "zod";
// Primitives
const nameSchema = z.string().min(1).max(100);
const ageSchema = z.number().int().min(0).max(150);
const isActiveSchema = z.boolean();
const createdAtSchema = z.date();
// Objects
const UserSchema = z.object({
name: nameSchema,
age: ageSchema,
isActive: isActiveSchema,
});
// Arrays
const TagsSchema = z.array(z.string()).min(1).max(10);
// Enums
const RoleSchema = z.enum(["admin", "editor", "viewer"]);
// Unions
const IdSchema = z.union([z.string().uuid(), z.number().int().positive()]);
// Optional and nullable
const BioSchema = z.string().max(500).optional();
const AvatarSchema = z.string().url().nullable();Two ways to validate. parse() throws on failure, safeParse() returns a result object:
// Throws ZodError on invalid data
const user = UserSchema.parse(untrustedData);
// Returns { success: true, data } or { success: false, error }
const result = UserSchema.safeParse(untrustedData);
if (result.success) {
console.log(result.data); // fully typed
} else {
console.log(result.error.issues); // structured error details
}I use safeParse() in every production path. Throwing errors in API routes or Server Actions creates noisy error handling. safeParse() gives you control over the response shape.
Form Validation with Server Actions
Server Actions are where Zod earns its keep in Next.js. The form data comes from the client — you cannot trust any of it. Not the types, not the values, not the structure.
Here is a contact form pattern I use across multiple projects:
// lib/schemas/contact.ts
import { z } from "zod";
export const ContactFormSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters").max(100),
email: z.string().email("Please enter a valid email address"),
subject: z.enum(["general", "project", "support"], {
errorMap: () => ({ message: "Please select a valid subject" }),
}),
message: z.string().min(10, "Message must be at least 10 characters").max(5000),
budget: z.coerce.number().min(0).optional(),
});
export type ContactFormData = z.infer<typeof ContactFormSchema>;The Server Action that processes it:
// app/actions/contact.ts
"use server";
import { ContactFormSchema } from "@/lib/schemas/contact";
type ActionResult = {
success: boolean;
message: string;
errors?: Record<string, string[]>;
};
export async function submitContact(formData: FormData): Promise<ActionResult> {
const raw = {
name: formData.get("name"),
email: formData.get("email"),
subject: formData.get("subject"),
message: formData.get("message"),
budget: formData.get("budget"),
};
const result = ContactFormSchema.safeParse(raw);
if (!result.success) {
const fieldErrors: Record<string, string[]> = {};
for (const issue of result.error.issues) {
const field = issue.path[0]?.toString();
if (field) {
fieldErrors[field] = fieldErrors[field] || [];
fieldErrors[field].push(issue.message);
}
}
return { success: false, message: "Validation failed", errors: fieldErrors };
}
// result.data is fully typed as ContactFormData
await sendEmail(result.data);
return { success: true, message: "Message sent successfully" };
}Notice z.coerce.number() for the budget field. FormData values are always strings, so z.number() would reject "5000" as invalid. z.coerce converts the string to a number before validation. This is critical when working with form inputs — every value arrives as a string.
The client component that consumes this:
"use client";
import { useActionState } from "react";
import { submitContact } from "@/app/actions/contact";
export function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, null);
return (
<form action={formAction}>
<div>
<input name="name" required />
{state?.errors?.name && (
<p className="text-sm text-red-500">{state.errors.name[0]}</p>
)}
</div>
{/* ... other fields */}
<button type="submit" disabled={isPending}>
{isPending ? "Sending..." : "Send Message"}
</button>
{state?.message && (
<p className={state.success ? "text-green-500" : "text-red-500"}>
{state.message}
</p>
)}
</form>
);
}This pattern gives you server-side validation that returns structured errors the client can display field-by-field. No client-side validation library needed for the basic case. The form works without JavaScript, progressively enhances with React, and the validation logic lives in one place.
API Route Validation
API routes need to validate three things: the request body, query parameters, and route parameters. I create a small validation helper that standardizes the pattern across every route:
// lib/api/validate.ts
import { z, ZodSchema } from "zod";
import { NextResponse } from "next/server";
type ValidationResult<T> =
| { success: true; data: T }
| { success: false; response: NextResponse };
export function validateBody<T>(
schema: ZodSchema<T>,
data: unknown
): ValidationResult<T> {
const result = schema.safeParse(data);
if (!result.success) {
return {
success: false,
response: NextResponse.json(
{
code: "VALIDATION_ERROR",
message: "Invalid request body",
details: result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
},
{ status: 400 }
),
};
}
return { success: true, data: result.data };
}Using it in a Route Handler:
// app/api/products/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { validateBody } from "@/lib/api/validate";
const CreateProductSchema = z.object({
name: z.string().min(1).max(200),
price: z.number().positive(),
description: z.string().max(2000).optional(),
categoryId: z.string().uuid(),
tags: z.array(z.string()).max(10).default([]),
});
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
if (!body) {
return NextResponse.json(
{ code: "INVALID_JSON", message: "Request body must be valid JSON" },
{ status: 400 }
);
}
const validation = validateBody(CreateProductSchema, body);
if (!validation.success) return validation.response;
const product = await db.product.create({ data: validation.data });
return NextResponse.json(product, { status: 201 });
}For query parameters, I parse the URLSearchParams into a plain object first:
const ProductListQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
category: z.string().uuid().optional(),
sort: z.enum(["price", "name", "createdAt"]).default("createdAt"),
order: z.enum(["asc", "desc"]).default("desc"),
});
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = Object.fromEntries(searchParams.entries());
const result = ProductListQuerySchema.safeParse(query);
if (!result.success) {
return NextResponse.json(
{ code: "INVALID_QUERY", message: "Invalid query parameters" },
{ status: 400 }
);
}
const { page, limit, category, sort, order } = result.data;
// Query is now fully typed and validated
}The z.coerce.number() is essential here too — query parameters are always strings. The .default() calls mean callers can omit optional parameters and get sane defaults. Your downstream code never deals with undefined.
Environment Variable Validation
This is the pattern that catches deployment bugs before they reach production. If you have ever deployed a Next.js app and spent 20 minutes debugging why Stripe payments fail, only to discover you forgot to set STRIPE_SECRET_KEY in your Vercel environment — this is the fix.
// lib/env.ts
import { z } from "zod";
const envSchema = z.object({
// Database
DATABASE_URL: z.string().url(),
// Auth
NEXTAUTH_SECRET: z.string().min(32),
NEXTAUTH_URL: z.string().url(),
// Stripe
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith("pk_"),
// Supabase
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
// App
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});
export const env = envSchema.parse(process.env);Import env instead of accessing process.env directly. If any variable is missing or malformed, the app crashes immediately at startup with a clear error message listing exactly which variables failed validation. You find out in CI, not in production at 3 AM.
The startsWith() checks on Stripe keys are a nice safety net — if someone accidentally puts a publishable key where the secret key should go, the validation catches it. Same principle applies to any key with a known prefix.
I put this file at lib/env.ts and import it everywhere:
import { env } from "@/lib/env";
const stripe = new Stripe(env.STRIPE_SECRET_KEY);
// env.STRIPE_SECRET_KEY is typed as string, never undefinedNo more process.env.STRIPE_SECRET_KEY! with a non-null assertion. No more as string casts. The validation guarantees the value exists and matches the expected format.
Validating External API Responses
This is the pattern most developers skip, and it bites them hardest. You call a third-party API, assume the response matches some TypeScript interface you wrote by reading their docs six months ago, and everything works — until they change their response format and your app silently starts writing corrupt data.
// lib/schemas/weather-api.ts
import { z } from "zod";
const WeatherResponseSchema = z.object({
location: z.object({
name: z.string(),
country: z.string(),
lat: z.number(),
lon: z.number(),
}),
current: z.object({
temp_c: z.number(),
humidity: z.number().int().min(0).max(100),
condition: z.object({
text: z.string(),
icon: z.string().url(),
}),
wind_kph: z.number(),
uv: z.number().min(0),
}),
});
type WeatherResponse = z.infer<typeof WeatherResponseSchema>;
export async function getWeather(city: string): Promise<WeatherResponse> {
const response = await fetch(
`https://api.weatherapi.com/v1/current.json?key=${env.WEATHER_API_KEY}&q=${city}`
);
if (!response.ok) {
throw new Error(`Weather API returned ${response.status}`);
}
const data = await response.json();
return WeatherResponseSchema.parse(data);
}If the API changes its response shape, parse() throws immediately with a clear error instead of letting bad data propagate through your system. You find out at the API call site, not three database operations later when something explodes.
For APIs where you only care about a subset of the response, use .passthrough() or .pick():
// Only validate fields you actually use, ignore the rest
const PartialWeatherSchema = WeatherResponseSchema.pick({
current: true,
});
// Or allow extra fields to pass through without stripping them
const FlexibleSchema = WeatherResponseSchema.passthrough();I have a rule: every external API response gets a Zod schema. No exceptions. If the API docs say the response has a data field that is an array of objects, I write a schema for that. When the next breaking change happens, my app tells me exactly what changed instead of silently corrupting state.
Custom Error Messages
Default Zod error messages are technically correct but terrible for end users. "Expected string, received number" is a great error message for a developer. It is a terrible one for someone filling out a form.
Zod v4 gives you several ways to customize messages:
const SignupSchema = z.object({
username: z
.string({ required_error: "Username is required" })
.min(3, "Username must be at least 3 characters")
.max(30, "Username cannot exceed 30 characters")
.regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores"),
password: z
.string({ required_error: "Password is required" })
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[0-9]/, "Password must contain at least one number")
.regex(/[^a-zA-Z0-9]/, "Password must contain at least one special character"),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});The refine() method handles cross-field validation — checks that depend on multiple fields. The path option tells Zod which field to attach the error to, so your form can display it in the right place.
For more complex transformations, transform() lets you modify data after validation:
const EmailSchema = z
.string()
.email("Please enter a valid email")
.transform((email) => email.toLowerCase().trim());
const SlugSchema = z
.string()
.min(1)
.transform((s) => s.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""));The input is validated first, then the transform runs. The inferred type reflects the output of the transform, not the input. This means z.infer<typeof EmailSchema> is string, but the value is always lowercase and trimmed. Clean data from the moment it enters your system.
Zod with React Hook Form
For complex forms with lots of client-side interactivity — multi-step forms, dynamic field arrays, conditional fields — I pair Zod with React Hook Form using the @hookform/resolvers package. This gives you client-side validation that matches your server-side validation exactly, because both use the same Zod schema.
npm install react-hook-form @hookform/resolvers"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const ProjectFormSchema = z.object({
title: z.string().min(1, "Title is required").max(200),
description: z.string().min(10, "Please provide more detail").max(5000),
budget: z.coerce.number().positive("Budget must be a positive number"),
deadline: z.coerce.date().min(new Date(), "Deadline must be in the future"),
tech: z.array(z.string()).min(1, "Select at least one technology"),
priority: z.enum(["low", "medium", "high"]),
});
type ProjectFormData = z.infer<typeof ProjectFormSchema>;
export function ProjectForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ProjectFormData>({
resolver: zodResolver(ProjectFormSchema),
defaultValues: {
priority: "medium",
tech: [],
},
});
async function onSubmit(data: ProjectFormData) {
// data is fully validated and typed
const response = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
// Handle server-side errors
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="title">Project Title</label>
<input id="title" {...register("title")} />
{errors.title && (
<p className="text-sm text-red-500">{errors.title.message}</p>
)}
</div>
<div>
<label htmlFor="budget">Budget (USD)</label>
<input id="budget" type="number" {...register("budget")} />
{errors.budget && (
<p className="text-sm text-red-500">{errors.budget.message}</p>
)}
</div>
<div>
<label htmlFor="priority">Priority</label>
<select id="priority" {...register("priority")}>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Project"}
</button>
</form>
);
}The key principle: share the schema between client and server. Define it in a shared lib/schemas/ directory, import it in your React Hook Form component for client-side validation, and import the same schema in your Server Action or API route for server-side validation. One schema, two enforcement points, zero possibility of drift.
lib/schemas/project.ts <-- Single source of truth
|
+--> app/projects/form.tsx (client: zodResolver)
+--> app/actions/create-project.ts (server: safeParse)Performance Considerations
Zod schemas are created once and reused. The schema definition itself is fast — it is just building an object that describes the validation rules. The actual validation (calling .parse() or .safeParse()) is where the work happens, and for most web application payloads, it is negligible.
That said, here are the things I have learned matter:
Define schemas at module scope, not inside functions. Schema creation has overhead. If you define a schema inside a request handler, you are recreating it on every request for no reason.
// Good: created once at module load
const ProductSchema = z.object({
name: z.string(),
price: z.number(),
});
export async function POST(request: Request) {
const body = await request.json();
const result = ProductSchema.safeParse(body);
}
// Bad: recreated on every request
export async function POST(request: Request) {
const ProductSchema = z.object({ // Don't do this
name: z.string(),
price: z.number(),
});
}Use `.strip()` for untrusted input. By default, Zod's z.object() strips unknown keys. This is the behavior you want for API inputs — it prevents clients from sending extra fields that might confuse downstream code or bypass authorization logic. If you need to preserve unknown keys, use .passthrough() explicitly.
Avoid deeply nested `.refine()` chains for hot paths. Each .refine() adds a function call. For simple validations, prefer built-in methods (.min(), .max(), .email()) over custom refinements. Built-in methods are optimized; refinements are arbitrary functions.
Lazy schemas for recursive types. If you have a type that references itself — like a comment thread or a category tree — use z.lazy():
type Category = {
name: string;
children: Category[];
};
const CategorySchema: z.ZodType<Category> = z.object({
name: z.string(),
children: z.lazy(() => z.array(CategorySchema)),
});Without z.lazy(), you would get infinite recursion during schema construction.
My Validation Patterns
After using Zod on every project for over a year, I have settled on patterns that I reuse without thinking. These are the conventions that make validation consistent across an entire codebase.
Pattern 1: Schema files live in `lib/schemas/`. One file per domain entity. The file exports the schema and the inferred type. Nothing else.
// lib/schemas/order.ts
import { z } from "zod";
export const CreateOrderSchema = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(99),
})).min(1, "Order must have at least one item"),
shippingAddress: z.object({
street: z.string().min(1),
city: z.string().min(1),
postalCode: z.string().regex(/^\d{5}(-\d{4})?$/),
country: z.string().length(2),
}),
notes: z.string().max(500).optional(),
});
export type CreateOrderInput = z.infer<typeof CreateOrderSchema>;
export const UpdateOrderStatusSchema = z.object({
status: z.enum(["processing", "shipped", "delivered", "cancelled"]),
trackingNumber: z.string().optional(),
});
export type UpdateOrderStatusInput = z.infer<typeof UpdateOrderStatusSchema>;Pattern 2: Reusable field schemas. Common fields like emails, phone numbers, and UUIDs get their own schemas that I compose into larger objects:
// lib/schemas/fields.ts
import { z } from "zod";
export const emailField = z.string().email("Invalid email address").max(254);
export const phoneField = z.string().regex(/^\+[1-9]\d{1,14}$/, "Use international format: +1234567890");
export const uuidField = z.string().uuid("Invalid ID format");
export const slugField = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Invalid slug format");
export const urlField = z.string().url("Invalid URL").max(2048);
export const paginationSchema = z.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});Then composing them:
import { emailField, phoneField } from "@/lib/schemas/fields";
const CustomerSchema = z.object({
email: emailField,
phone: phoneField.optional(),
name: z.string().min(1).max(100),
});Pattern 3: Discriminated unions for polymorphic payloads. When an API endpoint handles different types of requests based on a discriminator field:
const NotificationSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("email"),
to: emailField,
subject: z.string().min(1),
body: z.string().min(1),
}),
z.object({
type: z.literal("sms"),
to: phoneField,
message: z.string().min(1).max(160),
}),
z.object({
type: z.literal("push"),
userId: uuidField,
title: z.string().min(1),
body: z.string().min(1),
}),
]);Discriminated unions are more efficient than regular unions because Zod checks the discriminator field first to determine which branch to validate, instead of trying every option.
Pattern 4: Preprocess for messy real-world data. When integrating with systems that send inconsistent data — legacy APIs, CSV imports, webhook payloads — use z.preprocess() to clean before validation:
const LegacyProductSchema = z.object({
name: z.string(),
price: z.preprocess(
(val) => (typeof val === "string" ? parseFloat(val) : val),
z.number().positive()
),
active: z.preprocess(
(val) => (val === "true" || val === "1" || val === true),
z.boolean()
),
tags: z.preprocess(
(val) => (typeof val === "string" ? val.split(",").map((t) => t.trim()) : val),
z.array(z.string())
),
});This keeps the transformation logic co-located with the validation. You do not need a separate data cleaning step before validation — Zod handles both.
Key Takeaways
- TypeScript types disappear at runtime. Zod schemas survive. Use both: types for internal code, schemas for system boundaries.
- Validate at every boundary. Form submissions. API request bodies. Query parameters. Environment variables. Third-party API responses. If data crosses a trust boundary, validate it.
- Single source of truth. Define schemas in
lib/schemas/, derive TypeScript types withz.infer. Never maintain types and validation separately. - Use `safeParse()` in production code. It gives you control over error responses instead of letting exceptions bubble.
- `z.coerce` for form and query data. HTML forms and URL parameters are always strings. Coerce them to the correct type during validation.
- Environment validation at startup. Crash early with a clear error, not late with a cryptic one.
- External API responses are untrusted. Parse them through a schema. When the third-party changes their contract, you find out immediately.
- Share schemas between client and server. React Hook Form with
zodResolveron the client,safeParse()in your Server Action. Same schema, two enforcement points.
Runtime validation is not optional. TypeScript is a development tool. Zod is a production tool. Use them together, validate at the edges, and sleep better knowing your app rejects bad data before it does damage.
*Need a Next.js application built with production-grade validation, type safety, and the patterns that keep real apps running clean? Check out my services — I bring these exact patterns to every project I build.*
Uvin Vindula is a full-stack and Web3 engineer based between Sri Lanka and the UK, building production-grade applications with Next.js, TypeScript, and Solidity. He writes about the patterns that survive contact with real users at iamuvin.com↗.
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.