Zod is a JavaScript and TypeScript library for defining schemas to validate data and infer TypeScript types from those schemas. It’s particularly useful in Next.js projects for validating API inputs, form data, environment variables, and more. Unlike other validation libraries (e.g., Joi or Yup), Zod is designed with TypeScript in mind, offering seamless type inference without requiring additional type definitions.

Key benefits:

  • Type safety: Zod schemas automatically generate TypeScript types.
  • Immutability: Schemas are immutable, ensuring predictable behavior.
  • Minimal bundle size: Lightweight and tree-shakeable.
  • No external dependencies: Works out of the box in Next.js projects.

2. Installation

To use Zod in a Next.js + TypeScript project, install it via npm or yarn:

npm install zod
# or
yarn add zod

No additional TypeScript configuration is typically required, as Zod works with TypeScript’s type system natively.


3. Core Concepts

3.1 Schema Definition

Zod schemas define the shape of your data. You create schemas using Zod’s chainable API. Here are the basic building blocks:

import { z } from 'zod';

// Primitive schemas
const stringSchema = z.string(); // Validates a string
const numberSchema = z.number(); // Validates a number
const booleanSchema = z.boolean(); // Validates a boolean

// Example: User schema
const userSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  age: z.number().optional(), // Optional field
});

3.2 Type Inference

Zod schemas can infer TypeScript types, eliminating the need to write separate interfaces. Use z.infer to extract the type:

type User = z.infer<typeof userSchema>;
// Equivalent to:
// interface User {
//   id: number;
//   name: string;
//   email: string;
//   age?: number;
// }

This is especially useful in Next.js for typing API responses, form data, or database queries.

3.3 Validation

Zod provides methods like .parse() and .safeParse() to validate data:

  • .parse(): Throws an error if validation fails.
  • .safeParse(): Returns an object with success, data, and error properties for safer validation.
const data = { id: 1, name: "Alice", email: "alice@example.com" };

// Using .parse()
try {
  const validatedData = userSchema.parse(data);
  console.log(validatedData); // Valid data
} catch (error) {
  console.error(error); // ZodError with detailed issues
}

// Using .safeParse()
const result = userSchema.safeParse(data);
if (result.success) {
  console.log(result.data); // Valid data
} else {
  console.error(result.error); // ZodError
}

In a Next.js API route, you might use .safeParse() to validate incoming request bodies:

import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

const userSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

export async function POST(req: NextRequest) {
  const body = await req.json();
  const result = userSchema.safeParse(body);

  if (!result.success) {
    return NextResponse.json({ errors: result.error.issues }, { status: 400 });
  }

  // Process valid data
  return NextResponse.json({ user: result.data });
}

3.4 Schema Methods

Zod provides chainable methods to refine validation rules:

  • Strings:
    • .min(length): Minimum length.
    • .max(length): Maximum length.
    • .email(): Validates email format.
    • .regex(pattern): Matches a regex pattern.
    • Example: z.string().min(3).email()
  • Numbers:
    • .min(value): Minimum value.
    • .max(value): Maximum value.
    • .int(): Ensures the number is an integer.
    • Example: z.number().min(18).int()
  • Objects:
    • .object({}): Defines an object schema.
    • .partial(): Makes all fields optional.
    • .pick({ keys }): Selects specific fields.
    • .omit({ keys }): Excludes specific fields.
  • Arrays
    • z.array(schema): Validates an array of a specific type.
    • .min(length): Minimum array length.
    • .max(length): Maximum array length.
    • Example: z.array(z.string()).min(1)
  • Optional and Nullable:
    • .optional(): Field can be undefined.
    • .nullable(): Field can be null.
    • Example: z.string().optional().nullable()

3.5 Custom Validation

Use .refine() or .superRefine() for custom validation logic:

const userSchema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords must match",
  path: ["confirmPassword"], // Where the error is reported
});

3.6 Transformations

Zod allows transforming data during parsing with .transform():

const stringToNumber = z.string().transform((val) => parseInt(val, 10));
const result = stringToNumber.parse("123"); // Output: 123 (number)

This is useful in Next.js for transforming API inputs (e.g., converting string IDs to numbers).

3.7 Error Handling

Zod errors (ZodError) provide detailed information about validation failures:

const result = userSchema.safeParse({ id: "1", name: "", email: "invalid" });
if (!result.success) {
  console.log(result.error.issues);
  // Example output:
  // [
  //   { path: ["id"], message: "Expected number, received string" },
  //   { path: ["name"], message: "Expected non-empty string" },
  //   { path: ["email"], message: "Invalid email" }
  // ]
}

4. Advanced Features

4.1 Nested Schemas

Zod supports nested objects and arrays:

const addressSchema = z.object({
  street: z.string(),
  city: z.string(),
});

const userSchema = z.object({
  name: z.string(),
  address: addressSchema,
  hobbies: z.array(z.string()),
});

type User = z.infer<typeof userSchema>;
// { name: string, address: { street: string, city: string }, hobbies: string[] }

4.2 Union Types

Zod supports union types with z.union() or z.enum():

const statusSchema = z.enum(["active", "inactive"]);
const idSchema = z.union([z.string(), z.number()]);

const userSchema = z.object({
  status: statusSchema,
  id: idSchema,
});

4.3 Discriminated Unions

For complex unions, use z.discriminatedUnion():

const responseSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("success"), data: z.string() }),
  z.object({ type: z.literal("error"), message: z.string() }),
]);

const result = responseSchema.parse({ type: "success", data: "Done" });

4.4 Default Values

Set default values with .default():

const userSchema = z.object({
  name: z.string(),
  role: z.string().default("user"),
});

const data = userSchema.parse({ name: "Alice" }); // { name: "Alice", role: "user" }

4.5 Async Validation

Zod supports async validation with .refine():

const usernameSchema = z.string().refine(async (username) => {
  // Simulate async DB check
  const exists = await checkUsernameExists(username);
  return !exists;
}, { message: "Username already taken" });

In Next.js, this is useful for validating form inputs against a database in API routes or server actions.

4.6 Environment Variables

Zod is commonly used to validate environment variables in Next.js projects:

import { z } from 'zod';

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(1),
});

const env = envSchema.parse({
  DATABASE_URL: process.env.DATABASE_URL,
  API_KEY: process.env.API_KEY,
});

// Type-safe access
console.log(env.DATABASE_URL); // Type: string

Place this in a lib/env.ts file and import it across your Next.js app.


5. Integration with Next.js

5.1 API Routes

Validate incoming requests in API routes:

import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

export async function POST(req: NextRequest) {
  const body = await req.json();
  const result = schema.safeParse(body);

  if (!result.success) {
    return NextResponse.json({ errors: result.error.issues }, { status: 400 });
  }

  // Process valid data
  return NextResponse.json({ message: "Success", data: result.data });
}

5.2 Form Validation

For client-side or server-side form validation (e.g., with react-hook-form):

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  name: z.string().min(1, "Name is required"),
  email: z.string().email("Invalid email"),
});

type FormData = z.infer<typeof schema>;

export default function Form() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema),
  });

  const onSubmit = (data: FormData) => {
    console.log("Valid data:", data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name")} />
      {errors.name && <p>{errors.name.message}</p>}
      <input {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}
      <button type="submit">Submit</button>
    </form>
  );
}

Install the resolver: npm install @hookform/resolvers.

5.3 Server Actions

In Next.js 13+ with server actions, validate inputs similarly:

'use server';

import { z } from 'zod';

const schema = z.object({
  title: z.string().min(1),
});

export async function createPost(formData: FormData) {
  const data = Object.fromEntries(formData);
  const result = schema.safeParse(data);

  if (!result.success) {
    return { success: false, errors: result.error.issues };
  }

  // Save to database
  return { success: true, data: result.data };
}

6. Best Practices

  • Centralize schemas: Define schemas in a lib/schemas.ts file for reuse across API routes, forms, and server actions.
  • Use .safeParse(): Prefer .safeParse() over .parse() in production to avoid unhandled exceptions.
  • Leverage type inference: Use z.infer to avoid duplicating type definitions.
  • Validate early: Validate inputs at the entry point (e.g., API routes or forms) to ensure data integrity.
  • Handle errors gracefully: Use result.error.issues to provide user-friendly error messages.

7. Common Pitfalls

  • Overcomplicating schemas: Keep schemas simple and compose them using z.object, z.union, etc., for complex types.
  • Forgetting async validation: Ensure async validations are awaited properly in server-side code.
  • Ignoring error messages: Customize error messages with .refine() or schema options for better UX.
  • Not tree-shaking: Import only what you need (e.g., import { z } from 'zod') to minimize bundle size.

8. Example: Full Next.js Use Case

Here’s a complete example combining API validation, form handling, and environment variables:

// lib/schemas.ts
import { z } from 'zod';

export const envSchema = z.object({
  API_URL: z.string().url(),
});

export const userSchema = z.object({
  name: z.string().min(1, "Name is required"),
  email: z.string().email("Invalid email"),
  age: z.number().min(18, "Must be 18 or older").optional(),
});

// lib/env.ts
import { envSchema } from './schemas';

export const env = envSchema.parse({
  API_URL: process.env.API_URL,
});

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { userSchema } from '@/lib/schemas';

export async function POST(req: NextRequest) {
  const body = await req.json();
  const result = userSchema.safeParse(body);

  if (!result.success) {
    return NextResponse.json({ errors: result.error.issues }, { status: 400 });
  }

  // Call external API or database
  return NextResponse.json({ user: result.data });
}

// app/components/UserForm.tsx
'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { userSchema } from '@/lib/schemas';

type FormData = z.infer<typeof userSchema>;

export default function UserForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(userSchema),
  });

  const onSubmit = async (data: FormData) => {
    const res = await fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify(data),
      headers: { 'Content-Type': 'application/json' },
    });
    const result = await res.json();
    console.log(result);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name")} />
      {errors.name && <p>{errors.name.message}</p>}
      <input {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}
      <input type="number" {...register("age", { valueAsNumber: true })} />
      {errors.age && <p>{errors.age.message}</p>}
      <button type="submit">Submit</button>
    </form>
  );
}

9. Resources

  • Official Docs: Zod GitHub
  • API Reference: Comprehensive list of Zod methods.
  • Community: Check posts on X for real-time discussions or examples of Zod in Next.js projects (I can search if needed).

Comments