We've been burned by bad data more times than we'd like to admit. A user submitting a negative price. An external API returning null where we expected a string. A form field that should be an email but accepts 'asdfjkl;'. Every single one of these caused a bug that took longer to debug than it should have — because we trusted data we had no business trusting.
Zod fixed most of that. Not by magic, but by forcing us to be explicit: here's what I expect, and here's what happens when reality doesn't match. This post is how we actually use it — not a rehash of the docs, but the patterns we reach for in real Next.js apps.
Why Zod and Not Just TypeScript Types
TypeScript is great. But TypeScript types disappear at runtime. You can type a function parameter as `string` all day long — if someone calls that function with garbage at runtime, TypeScript shrugs. This catches you in two places: user input (never trust it) and external data (API responses, webhook payloads, database rows from raw queries).
Zod gives you both a runtime validator AND the TypeScript type, generated from the same schema. Write it once, get both. That's the deal, and it's a good one.
import { z } from 'zod';
// Define the schema once
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(['admin', 'user', 'viewer']),
createdAt: z.coerce.date(),
});
// Get the TypeScript type for free
type User = z.infer<typeof UserSchema>;
// Now validate at runtime
const result = UserSchema.safeParse(someUnknownData);
if (!result.success) {
console.error(result.error.flatten());
} else {
// result.data is fully typed as User
console.log(result.data.email);
}Notice `safeParse` instead of `parse`. `parse` throws on failure; `safeParse` returns a result object. In most app code, you want `safeParse` — throwing errors from validation feels violent. Save `parse` for cases where invalid data is truly unrecoverable (like reading a config file on startup).
Form Validation with React Hook Form + Zod
The most common pattern we use: define the Zod schema, plug it into React Hook Form via the `@hookform/resolvers` package, and let the resolver handle the wiring. No manual error state, no `useState` for each field error.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const SignupSchema = z.object({
email: z.string().email('Please enter a valid email'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain at least one uppercase letter')
.regex(/[0-9]/, 'Must contain at least one number'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
type SignupForm = z.infer<typeof SignupSchema>;
export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupForm>({
resolver: zodResolver(SignupSchema),
});
const onSubmit = async (data: SignupForm) => {
// data is fully typed and already validated
await createUser(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} type="email" />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password')} type="password" />
{errors.password && <span>{errors.password.message}</span>}
<input {...register('confirmPassword')} type="password" />
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
<button type="submit" disabled={isSubmitting}>
Sign up
</button>
</form>
);
}The `.refine()` at the end is how you do cross-field validation in Zod — cases where the validity of one field depends on another. The `path` tells it which field to attach the error to. Without `path`, the error floats at the root of the form, which is awkward to display.
Server Actions: Validate Before You Touch the Database
With Next.js Server Actions, you've got a direct line from form to server. That's powerful, but it also means you need to validate on the server side too — never assume the client-side validation caught everything. Users can disable JavaScript, call your endpoints directly, or just be creative.
'use server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
const CreatePostSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
content: z.string().min(10, 'Content is too short'),
published: z.boolean().default(false),
tags: z.array(z.string()).max(5, 'Maximum 5 tags allowed').default([]),
});
type ActionResult =
| { success: true; postId: string }
| { success: false; errors: Record<string, string[]> };
export async function createPost(
_prevState: ActionResult | null,
formData: FormData
): Promise<ActionResult> {
const raw = {
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'true',
tags: formData.getAll('tags'),
};
const result = CreatePostSchema.safeParse(raw);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
const post = await db.post.create({
data: {
...result.data,
authorId: 'current-user-id', // from session
},
});
revalidatePath('/posts');
return { success: true, postId: post.id };
}The `flatten()` method on the Zod error is useful here — it gives you `fieldErrors` (keyed by field name) and `formErrors` (root-level errors). Perfect for returning structured error info back to the client.
Always validate on the server, even if you're validating on the client with the same schema. Client validation is UX. Server validation is security.
Validating External API Responses
This is where most developers leave money on the table. They'll write careful Zod schemas for user input, then do `const data = await response.json() as SomeType` for external APIs. That `as SomeType` cast is a lie you're telling TypeScript. The API might have changed, the field might be null, the structure might be subtly different in edge cases.
We validate external API responses, especially for anything touching money, auth, or data we're about to write to the database.
import { z } from 'zod';
// Stripe-like payment intent response (simplified)
const PaymentIntentSchema = z.object({
id: z.string().startsWith('pi_'),
amount: z.number().int().positive(),
currency: z.string().length(3),
status: z.enum([
'requires_payment_method',
'requires_confirmation',
'processing',
'succeeded',
'canceled',
]),
metadata: z.record(z.string()).default({}),
created: z.number(),
});
type PaymentIntent = z.infer<typeof PaymentIntentSchema>;
async function fetchPaymentIntent(id: string): Promise<PaymentIntent> {
const response = await fetch(`https://api.stripe.com/v1/payment_intents/${id}`, {
headers: {
Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
},
});
if (!response.ok) {
throw new Error(`Stripe API error: ${response.status}`);
}
const raw = await response.json();
const result = PaymentIntentSchema.safeParse(raw);
if (!result.success) {
// Log the actual error for debugging, but don't expose internals
console.error('Unexpected Stripe response shape:', result.error.format());
throw new Error('Unexpected payment data format');
}
return result.data;
}Real talk: we caught a Stripe API response change this way. A field we were using went from `string | null` to just `string | undefined` in some edge cases. Without validation, it silently broke. With Zod, we got a clear error in our logs pointing exactly at the problem.
Reusing Schemas Across the Stack
One underrated Zod feature: you can build schemas from other schemas using `.extend()`, `.omit()`, `.pick()`, and `.partial()`. This is how you avoid duplicating validation logic between your 'create' and 'update' endpoints.
import { z } from 'zod';
// Base schema — the full shape of a product
const ProductSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(200),
price: z.number().positive(),
description: z.string().optional(),
inventory: z.number().int().min(0),
createdAt: z.date(),
updatedAt: z.date(),
});
// Create: omit server-generated fields
const CreateProductSchema = ProductSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
});
// Update: everything is optional except id
const UpdateProductSchema = ProductSchema
.omit({ createdAt: true, updatedAt: true })
.partial()
.required({ id: true }); // id is always required
// API response: the full thing
const ProductResponseSchema = ProductSchema;
// List response: wrapped with pagination
const ProductListSchema = z.object({
items: z.array(ProductResponseSchema),
total: z.number().int(),
page: z.number().int(),
pageSize: z.number().int(),
});
type CreateProduct = z.infer<typeof CreateProductSchema>;
type UpdateProduct = z.infer<typeof UpdateProductSchema>;
type ProductList = z.infer<typeof ProductListSchema>;Put these shared schemas in a `/schemas` or `/lib/validations` directory and import them from both client and server code. When you need to change the shape of a product, you change it in one place. Everything else either adapts or screams at you with a type error, which is exactly what you want.
A Few Zod Tricks Worth Knowing
After using Zod daily for a couple of years, here are the less-obvious features that come up constantly:
- `z.coerce.date()` — converts strings and numbers to Date objects. Saves you from the 'it's a string but I need a Date' dance with form submissions.
- `z.preprocess()` — run a transformation before validation. Useful for stripping whitespace, converting 'true'/'false' strings to booleans, or normalizing phone numbers.
- `.transform()` — convert the validated value to a different type or shape. Run it after validation passes. Great for converting cents to dollars, or normalizing emails to lowercase.
- `.superRefine()` — async validation with custom error paths. Use it when you need to check uniqueness against the database during validation.
- `z.discriminatedUnion()` — way more efficient than regular `.union()` when your types have a shared 'type' or 'kind' field. Zod knows which branch to try based on the discriminator value.
// z.preprocess example — handle string booleans from FormData
const FormBooleanSchema = z.preprocess(
(val) => val === 'true' || val === true,
z.boolean()
);
// .transform example — store price in cents, display in dollars
const PriceSchema = z
.string()
.regex(/^\d+(\.\d{1,2})?$/, 'Invalid price format')
.transform((val) => Math.round(parseFloat(val) * 100)); // converts to cents
// discriminatedUnion example — webhook event handling
const WebhookEventSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('payment.succeeded'),
paymentId: z.string(),
amount: z.number(),
}),
z.object({
type: z.literal('subscription.cancelled'),
subscriptionId: z.string(),
canceledAt: z.coerce.date(),
}),
z.object({
type: z.literal('user.created'),
userId: z.string().uuid(),
email: z.string().email(),
}),
]);
type WebhookEvent = z.infer<typeof WebhookEventSchema>;
function handleWebhook(event: WebhookEvent) {
switch (event.type) {
case 'payment.succeeded':
// TypeScript knows event.paymentId and event.amount exist here
break;
case 'subscription.cancelled':
// TypeScript knows event.subscriptionId and event.canceledAt exist here
break;
}
}The discriminated union pattern for webhooks is particularly satisfying. You define every event type you handle, validate the incoming payload, and TypeScript narrows the type in each switch branch. If Stripe sends you an event shape you didn't account for, validation fails loudly instead of silently doing nothing.
When Zod Gets Annoying (And What to Do)
Zod isn't perfect. Bundle size is a real concern if you're using it on the client — the library is around 14kb gzipped. For most apps that's fine, but if you're building a landing page and adding Zod just for a contact form, consider the native Constraint Validation API or a lighter library like Valibot.
The other gotcha: deeply nested `.refine()` calls can get hard to read fast. When that happens, break the schema into smaller pieces and compose them. A schema that takes 200 lines to read isn't protecting you — it's just making debugging harder.
Also: `.transform()` and `.refine()` run in order, and transforms run after refinements. If you transform first and then refine on the transformed value, you need `.superRefine()` after the transform. This trips people up constantly, including us.
If your Zod schema is longer than the function that uses it, you probably need to split either the schema or the function.
The End-to-End Pattern We Actually Ship
Here's the complete flow we use in the peal.dev templates for any feature that involves user input hitting the database: one shared schema file, client-side validation via react-hook-form, server-side validation in the server action, and API response validation whenever we're consuming external services.
The schemas live in `/lib/schemas/` and get imported everywhere they're needed. When a product requirement changes — say, the max length for a title goes from 100 to 200 characters — we change it in one place. The form, the server action, and any docs that reference the schema all stay in sync automatically.
One thing that saves us repeatedly: putting Zod validation at the very start of server actions and API route handlers, before touching auth or the database. Bad data fails fast and cheap. Catching it after a database round-trip (or worse, after writing bad data) is expensive.
Parse, don't validate. Zod's job isn't just to say 'this data is wrong' — it's to transform unknown data into a trusted, typed shape your app can actually use.
If you want to see these patterns applied to a real project — auth, billing, forms, API routes, the whole thing — the peal.dev templates come pre-wired with Zod schemas for all of it. The validation layer is already there; you just customize the schemas to match your domain.
Start with your most untrusted data source (probably your forms or your worst third-party API), add a Zod schema, fix the five type errors that show up, and you'll be sold. We were.
