DEV Community

Anas Sheikh
Anas Sheikh

Posted on

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('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

Top comments (0)