Every production Next.js app needs input validation.
Most developers do it manually:
if (!body.email) return error('Email required');
if (!body.email.includes('@')) return error('Invalid email');
if (!body.password) return error('Password required');
if (body.password.length < 8) return error('Password too short');
Enter fullscreen mode Exit fullscreen mode
This is error-prone, verbose, and gives you no TypeScript types.
Zod fixes all of this.
Install
npm install zod
Enter fullscreen mode Exit fullscreen mode
Basic Schema
import { z } from 'zod';
const UserSchema = z.object({
name: z.string().min(2, 'Name too short').max(50),
email: z.string().email('Invalid email'),
age: z.number().min(18, 'Must be 18+').optional(),
role: z.enum(['admin', 'user']).default('user'),
});
// TypeScript type — automatically inferred
type User = z.infer<typeof UserSchema>;
// { name: string; email: string; age?: number; role: 'admin' | 'user' }
Enter fullscreen mode Exit fullscreen mode
You get runtime validation AND TypeScript types from the same definition.
Using in API Routes
// app/api/users/route.ts
import { NextRequest } from 'next/server';
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Please enter a valid email'),
password: z.string()
.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'),
});
export async function POST(request: NextRequest) {
const body = await request.json();
// safeParse never throws — returns result object
const result = CreateUserSchema.safeParse(body);
if (!result.success) {
return Response.json(
{
error: 'Validation failed',
// First error message — clean for users
message: result.error.errors[0].message,
// All errors — useful for forms
errors: result.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
// result.data is fully typed here
const { name, email, password } = result.data;
// Save to database...
return Response.json({ success: true });
}
Enter fullscreen mode Exit fullscreen mode
Using in Server Actions
// actions/auth.ts
'use server';
import { z } from 'zod';
const LoginSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(1, 'Password is required'),
});
export async function login(
prevState: { error: string } | null,
formData: FormData
) {
const result = LoginSchema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
});
if (!result.success) {
return { error: result.error.errors[0].message };
}
const { email, password } = result.data;
// authenticate user...
}
Enter fullscreen mode Exit fullscreen mode
Common Zod Patterns
String Validations
z.string() // any string
z.string().min(3) // minimum length
z.string().max(100) // maximum length
z.string().email() // valid email
z.string().url() // valid URL
z.string().uuid() // valid UUID
z.string().regex(/^\d{4}$/) // matches pattern
z.string().trim() // trim whitespace
z.string().toLowerCase() // convert to lowercase
z.string().optional() // can be undefined
z.string().nullable() // can be null
z.string().default('guest') // default value
Enter fullscreen mode Exit fullscreen mode
Number Validations
z.number().min(0) // minimum value
z.number().max(100) // maximum value
z.number().int() // must be integer
z.number().positive() // must be > 0
z.number().nonnegative() // must be >= 0
z.coerce.number() // convert string "42" to number 42
Enter fullscreen mode Exit fullscreen mode
Object Patterns
// Partial — all fields optional
const UpdateUserSchema = UserSchema.partial();
// Pick — only specific fields
const LoginSchema = UserSchema.pick({ email: true, password: true });
// Omit — exclude specific fields
const PublicUserSchema = UserSchema.omit({ password: true });
// Extend — add fields to existing schema
const AdminSchema = UserSchema.extend({
permissions: z.array(z.string()),
});
Enter fullscreen mode Exit fullscreen mode
Validating Query Params
// app/api/posts/route.ts
const QuerySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(10),
search: z.string().optional(),
status: z.enum(['draft', 'published', 'all']).default('all'),
});
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const result = QuerySchema.safeParse({
page: searchParams.get('page'),
limit: searchParams.get('limit'),
search: searchParams.get('search'),
status: searchParams.get('status'),
});
if (!result.success) {
return Response.json({ error: 'Invalid query params' }, { status: 400 });
}
const { page, limit, search, status } = result.data;
// all values are typed and validated
}
Enter fullscreen mode Exit fullscreen mode
The z.coerce.number() is key — URL params are always strings, coerce converts them to numbers automatically.
Validating Environment Variables
// lib/env.ts
import { z } from 'zod';
const EnvSchema = z.object({
MONGODB_URI: z.string().url('Invalid MongoDB URI'),
JWT_SECRET: z.string().min(32, 'JWT secret must be at least 32 characters'),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().default(3000),
});
// Validate at startup — crashes immediately if env vars are missing
const result = EnvSchema.safeParse(process.env);
if (!result.success) {
console.error('Invalid environment variables:');
console.error(result.error.flatten().fieldErrors);
process.exit(1); // crash immediately with helpful error
}
export const env = result.data;
Enter fullscreen mode Exit fullscreen mode
Usage anywhere:
import { env } from '@/lib/env';
// env.MONGODB_URI is typed as string
// env.PORT is typed as number
Enter fullscreen mode Exit fullscreen mode
Form Error Display
'use client';
import { useActionState } from 'react';
import { submitForm } from '@/actions/form';
export function ContactForm() {
const [state, action] = useActionState(submitForm, null);
return (
<form action={action}>
<div>
<input name="email" type="email" />
{state?.errors?.email && (
<p className="text-red-400 text-sm mt-1">
{state.errors.email[0]}
</p>
)}
</div>
<button type="submit">Send</button>
</form>
);
}
Enter fullscreen mode Exit fullscreen mode
Summary
| Without Zod | With Zod |
|---|---|
| Manual if/else checks | One schema definition |
| No TypeScript types | Types auto-inferred |
| Inconsistent error messages | Consistent validation messages |
| Runtime crashes | Caught at validation |
| Repeated code | Reusable schemas |
Zod is one of those libraries where once you use it, you can't imagine going back.
I use it in every Next.js project and all my templates:
Get the templates: https://pixelanas.gumroad.com
What validation library do you use? Drop it below 👇
Anas — full-stack Next.js developer. X: @pixelanas
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.