Stop Manually Validating API Input — Use Zod in Next.js
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('Passw

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'); This is error-prone, verbose, and gives you no TypeScript types. Zod fixes all of this. npm install zod 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' } You get runtime validation AND TypeScript types from the same definition. // 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 }); } // 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... } 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 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 // 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()), }); // 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 } The z.coerce.number() is key — URL params are always strings, coerce converts them to numbers automatically. // 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; Usage anywhere: import { env } from '@/lib/env'; // env.MONGODB_URI is typed as string // env.PORT is typed as number '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> ); } 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: https://neurodash-dashbord.vercel.app/ https://og-ai-next.vercel.app/ Get the templates: https://pixelanas.gumroad.com What validation library do you use? Drop it below 👇 Anas — full-stack Next.js developer. X: @pixelanas
Key Takeaways
- •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('Passw
- •This story was reported by Dev.to, covering developments in the dev space.
- •AI advancements continue to reshape industries — read the full article on Dev.to for complete coverage.
📖 Continue reading the full article:
Read Full Article on Dev.to →


