Zod v4 with Next.js 15: Complete Schema Validation for Forms, APIs, and Server Actions

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Validate everything. Trust nothing. Zod v4 is the fastest TypeScript schema validation library, and it pairs perfectly with Next.js 15. In this tutorial, you will build a production-ready contact management app with bulletproof validation across forms, APIs, Server Actions, and environment variables.

What You Will Learn

By the end of this tutorial, you will:

  • Understand what changed in Zod v4 and why it matters
  • Define reusable schemas for your entire Next.js application
  • Validate Server Action inputs with Zod and useActionState
  • Secure API Route Handlers with request body and query param validation
  • Parse and validate environment variables at build time
  • Handle validation errors with user-friendly messages
  • Build type-safe forms that share schemas between client and server

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed (node --version)
  • TypeScript experience (types, generics, inference)
  • Next.js 15 familiarity (App Router, Server Components, Server Actions)
  • React 19 basics (useActionState, form actions)
  • A code editor — VS Code or Cursor recommended

Why Zod v4?

Zod has been the go-to schema validation library for TypeScript since 2022. Version 4 is a ground-up rewrite that brings massive performance improvements and new features:

FeatureZod v3Zod v4
Parse speedBaseline2-7x faster
Bundle size~57 KB~13 KB (77% smaller)
Tree-shakingLimitedFull ESM tree-shaking
Error messagesBasicRich, structured errors
JSON SchemaThird-partyBuilt-in z.toJSONSchema()
Template literalsNoz.templateLiteral()
MetadataNoz.registry() for forms/docs

The biggest wins are speed and size. Zod v4 parses schemas 2-7x faster than v3, which matters when you validate every API request and form submission. The 77% bundle reduction means less JavaScript shipped to the client.


Step 1: Project Setup

Create a new Next.js 15 project and install Zod v4:

npx create-next-app@latest zod-nextjs-demo --typescript --tailwind --eslint --app --src-dir --turbopack
cd zod-nextjs-demo

Install Zod v4:

npm install zod@^4

Verify you have Zod v4:

npx tsx -e "import {z} from 'zod'; console.log(z.version)"
# Should output 4.x.x

Your project structure will look like this:

src/
├── app/
│   ├── layout.tsx
│   ├── page.tsx
│   ├── contacts/
│   │   ├── page.tsx
│   │   ├── new/
│   │   │   └── page.tsx
│   │   └── [id]/
│   │       └── page.tsx
│   └── api/
│       └── contacts/
│           └── route.ts
├── lib/
│   ├── schemas.ts          # All Zod schemas
│   ├── env.ts              # Environment validation
│   └── actions.ts          # Server Actions
└── components/
    └── contact-form.tsx    # Reusable form component

Step 2: Define Your Schemas

The core principle of Zod is define once, use everywhere. Create a central schemas file that both client and server import.

Create src/lib/schemas.ts:

import { z } from "zod";
 
// Base contact schema — reused across forms, APIs, and actions
export const contactSchema = z.object({
  name: z
    .string()
    .min(2, "Name must be at least 2 characters")
    .max(100, "Name must be under 100 characters")
    .trim(),
 
  email: z
    .string()
    .email("Please enter a valid email address")
    .toLowerCase(),
 
  phone: z
    .string()
    .regex(/^\+?[\d\s-()]{7,15}$/, "Please enter a valid phone number")
    .optional()
    .or(z.literal("")),
 
  company: z
    .string()
    .max(200, "Company name is too long")
    .optional(),
 
  message: z
    .string()
    .min(10, "Message must be at least 10 characters")
    .max(5000, "Message must be under 5000 characters"),
 
  priority: z.enum(["low", "medium", "high", "urgent"], {
    message: "Please select a valid priority level",
  }),
});
 
// Infer TypeScript types from the schema
export type Contact = z.infer<typeof contactSchema>;
 
// Schema for updating (all fields optional except id)
export const contactUpdateSchema = contactSchema.partial().extend({
  id: z.string().uuid("Invalid contact ID"),
});
 
export type ContactUpdate = z.infer<typeof contactUpdateSchema>;
 
// Schema for search/filter query params
export const contactQuerySchema = z.object({
  q: z.string().optional().default(""),
  priority: z.enum(["low", "medium", "high", "urgent"]).optional(),
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  sort: z.enum(["name", "email", "createdAt"]).default("createdAt"),
  order: z.enum(["asc", "desc"]).default("desc"),
});
 
export type ContactQuery = z.infer<typeof contactQuerySchema>;

Key Zod v4 Patterns Used

1. Custom error messages inline:

In Zod v4, you pass error messages directly as the second argument to validators:

z.string().min(2, "Name must be at least 2 characters")

2. Coercion for query params:

z.coerce.number() automatically converts string query params like "5" to the number 5. This is essential for URL search parameters which are always strings.

3. Type inference with z.infer:

You never write TypeScript interfaces manually. Zod schemas ARE your types:

// This type is automatically:
// {
//   name: string;
//   email: string;
//   phone?: string | undefined;
//   company?: string | undefined;
//   message: string;
//   priority: "low" | "medium" | "high" | "urgent";
// }
export type Contact = z.infer<typeof contactSchema>;

Step 3: Validate Environment Variables

One of the most impactful uses of Zod is validating environment variables at startup. Catch misconfigurations before your app serves a single request.

Create src/lib/env.ts:

import { z } from "zod";
 
const envSchema = z.object({
  // Database
  DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
 
  // Auth
  AUTH_SECRET: z
    .string()
    .min(32, "AUTH_SECRET must be at least 32 characters"),
 
  // App
  NEXT_PUBLIC_APP_URL: z
    .string()
    .url()
    .default("http://localhost:3000"),
 
  NODE_ENV: z
    .enum(["development", "production", "test"])
    .default("development"),
 
  // Email (optional in development)
  SMTP_HOST: z.string().optional(),
  SMTP_PORT: z.coerce.number().default(587),
  SMTP_USER: z.string().optional(),
  SMTP_PASS: z.string().optional(),
});
 
// Parse and validate — throws at startup if invalid
function validateEnv() {
  const result = envSchema.safeParse(process.env);
 
  if (!result.success) {
    console.error("Invalid environment variables:");
    console.error(result.error.format());
    throw new Error("Invalid environment configuration");
  }
 
  return result.data;
}
 
export const env = validateEnv();
 
// Type-safe access: env.DATABASE_URL is string, env.SMTP_PORT is number

Now import env anywhere instead of using process.env directly:

import { env } from "@/lib/env";
 
// Type-safe, validated, with defaults applied
const dbUrl = env.DATABASE_URL; // string (guaranteed)
const port = env.SMTP_PORT;     // number (coerced from string)

Never import env.ts in client components. It accesses process.env which only exists on the server. For client-side env vars, use NEXT_PUBLIC_ prefixed variables directly.


Step 4: Server Actions with Zod Validation

Server Actions are the primary way to handle form submissions in Next.js 15. Zod makes them type-safe and secure.

Create src/lib/actions.ts:

"use server";
 
import { contactSchema, type Contact } from "./schemas";
 
// Action state type for useActionState
export type ActionState = {
  success: boolean;
  message: string;
  errors?: Record<string, string[]>;
  data?: Contact;
};
 
export async function createContact(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  // Extract raw form data
  const rawData = {
    name: formData.get("name"),
    email: formData.get("email"),
    phone: formData.get("phone"),
    company: formData.get("company"),
    message: formData.get("message"),
    priority: formData.get("priority"),
  };
 
  // Validate with Zod
  const result = contactSchema.safeParse(rawData);
 
  if (!result.success) {
    // Convert Zod errors to a flat object for the form
    const fieldErrors: Record<string, string[]> = {};
    for (const issue of result.error.issues) {
      const field = issue.path[0]?.toString() ?? "form";
      if (!fieldErrors[field]) fieldErrors[field] = [];
      fieldErrors[field].push(issue.message);
    }
 
    return {
      success: false,
      message: "Please fix the errors below.",
      errors: fieldErrors,
    };
  }
 
  // result.data is fully typed as Contact
  const validatedData = result.data;
 
  try {
    // In a real app, save to database
    console.log("Creating contact:", validatedData);
 
    // Simulate database insert
    await new Promise((resolve) => setTimeout(resolve, 500));
 
    return {
      success: true,
      message: `Contact "${validatedData.name}" created successfully!`,
      data: validatedData,
    };
  } catch (error) {
    return {
      success: false,
      message: "Something went wrong. Please try again.",
    };
  }
}

Why safeParse over parse?

  • parse() throws a ZodError on invalid input — great for API routes where you catch and return 400
  • safeParse() returns { success, data, error } — ideal for Server Actions where you need to return error state to the form

Step 5: Build the Validated Form

Create a form component that displays server-side validation errors with useActionState.

Create src/components/contact-form.tsx:

"use client";
 
import { useActionState } from "react";
import { createContact, type ActionState } from "@/lib/actions";
 
const initialState: ActionState = {
  success: false,
  message: "",
};
 
export function ContactForm() {
  const [state, formAction, isPending] = useActionState(
    createContact,
    initialState
  );
 
  return (
    <form action={formAction} className="space-y-6 max-w-lg">
      {/* Status message */}
      {state.message && (
        <div
          className={`p-4 rounded-lg ${
            state.success
              ? "bg-green-50 text-green-800 border border-green-200"
              : state.errors
              ? "bg-red-50 text-red-800 border border-red-200"
              : ""
          }`}
        >
          {state.message}
        </div>
      )}
 
      {/* Name */}
      <div>
        <label htmlFor="name" className="block text-sm font-medium mb-1">
          Name *
        </label>
        <input
          id="name"
          name="name"
          type="text"
          required
          className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
        />
        {state.errors?.name && (
          <p className="mt-1 text-sm text-red-600">{state.errors.name[0]}</p>
        )}
      </div>
 
      {/* Email */}
      <div>
        <label htmlFor="email" className="block text-sm font-medium mb-1">
          Email *
        </label>
        <input
          id="email"
          name="email"
          type="email"
          required
          className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
        />
        {state.errors?.email && (
          <p className="mt-1 text-sm text-red-600">{state.errors.email[0]}</p>
        )}
      </div>
 
      {/* Phone */}
      <div>
        <label htmlFor="phone" className="block text-sm font-medium mb-1">
          Phone
        </label>
        <input
          id="phone"
          name="phone"
          type="tel"
          className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
        />
        {state.errors?.phone && (
          <p className="mt-1 text-sm text-red-600">{state.errors.phone[0]}</p>
        )}
      </div>
 
      {/* Company */}
      <div>
        <label htmlFor="company" className="block text-sm font-medium mb-1">
          Company
        </label>
        <input
          id="company"
          name="company"
          type="text"
          className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
        />
        {state.errors?.company && (
          <p className="mt-1 text-sm text-red-600">
            {state.errors.company[0]}
          </p>
        )}
      </div>
 
      {/* Message */}
      <div>
        <label htmlFor="message" className="block text-sm font-medium mb-1">
          Message *
        </label>
        <textarea
          id="message"
          name="message"
          rows={4}
          required
          className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
        />
        {state.errors?.message && (
          <p className="mt-1 text-sm text-red-600">
            {state.errors.message[0]}
          </p>
        )}
      </div>
 
      {/* Priority */}
      <div>
        <label htmlFor="priority" className="block text-sm font-medium mb-1">
          Priority *
        </label>
        <select
          id="priority"
          name="priority"
          required
          className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
        >
          <option value="">Select priority</option>
          <option value="low">Low</option>
          <option value="medium">Medium</option>
          <option value="high">High</option>
          <option value="urgent">Urgent</option>
        </select>
        {state.errors?.priority && (
          <p className="mt-1 text-sm text-red-600">
            {state.errors.priority[0]}
          </p>
        )}
      </div>
 
      {/* Submit */}
      <button
        type="submit"
        disabled={isPending}
        className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
      >
        {isPending ? "Submitting..." : "Create Contact"}
      </button>
    </form>
  );
}

How This Works

  1. User fills out the form and clicks submit
  2. formAction sends the FormData to the createContact Server Action
  3. Zod validates all fields on the server
  4. If validation fails, errors are returned to the component via state.errors
  5. Each field displays its specific error message
  6. If validation passes, the contact is created and a success message is shown

The isPending state from useActionState handles the loading state automatically — no manual useState needed.


Step 6: Validate API Route Handlers

For API routes, use Zod to validate request bodies, query parameters, and path parameters.

Create src/app/api/contacts/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { contactSchema, contactQuerySchema } from "@/lib/schemas";
 
// GET /api/contacts?q=john&priority=high&page=1&limit=10
export async function GET(request: NextRequest) {
  const searchParams = Object.fromEntries(
    request.nextUrl.searchParams.entries()
  );
 
  // Validate query parameters
  const result = contactQuerySchema.safeParse(searchParams);
 
  if (!result.success) {
    return NextResponse.json(
      {
        error: "Invalid query parameters",
        details: result.error.issues.map((i) => ({
          field: i.path.join("."),
          message: i.message,
        })),
      },
      { status: 400 }
    );
  }
 
  const { q, priority, page, limit, sort, order } = result.data;
 
  // In a real app, query your database with these validated params
  console.log("Fetching contacts:", { q, priority, page, limit, sort, order });
 
  return NextResponse.json({
    contacts: [],
    pagination: { page, limit, total: 0 },
  });
}
 
// POST /api/contacts
export async function POST(request: NextRequest) {
  let body: unknown;
 
  try {
    body = await request.json();
  } catch {
    return NextResponse.json(
      { error: "Invalid JSON body" },
      { status: 400 }
    );
  }
 
  // Validate request body
  const result = contactSchema.safeParse(body);
 
  if (!result.success) {
    return NextResponse.json(
      {
        error: "Validation failed",
        details: result.error.issues.map((i) => ({
          field: i.path.join("."),
          message: i.message,
        })),
      },
      { status: 400 }
    );
  }
 
  const contact = result.data;
 
  // In a real app, insert into database
  console.log("Creating contact via API:", contact);
 
  return NextResponse.json(
    { id: crypto.randomUUID(), ...contact },
    { status: 201 }
  );
}

Reusable Validation Helper

If you have many API routes, extract a helper to reduce boilerplate:

// src/lib/validate.ts
import { z, type ZodType } from "zod";
import { NextResponse } from "next/server";
 
export function validateBody<T extends ZodType>(schema: T, data: unknown) {
  const result = schema.safeParse(data);
 
  if (!result.success) {
    return {
      success: false as const,
      response: NextResponse.json(
        {
          error: "Validation failed",
          details: result.error.issues.map((i) => ({
            field: i.path.join("."),
            message: i.message,
          })),
        },
        { status: 400 }
      ),
    };
  }
 
  return {
    success: true as const,
    data: result.data as z.infer<T>,
  };
}

Now your API routes become much cleaner:

export async function POST(request: NextRequest) {
  const body = await request.json().catch(() => null);
  if (!body) {
    return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
  }
 
  const validation = validateBody(contactSchema, body);
  if (!validation.success) return validation.response;
 
  // validation.data is fully typed as Contact
  const contact = validation.data;
  // ... save to database
}

Step 7: Advanced Zod v4 Patterns

Pattern 1: Discriminated Unions

Handle different form types with a single schema:

const notificationSchema = z.discriminatedUnion("channel", [
  z.object({
    channel: z.literal("email"),
    email: z.string().email(),
    subject: z.string().min(1),
  }),
  z.object({
    channel: z.literal("sms"),
    phone: z.string().regex(/^\+[\d]{10,15}$/),
  }),
  z.object({
    channel: z.literal("push"),
    deviceToken: z.string().min(1),
    title: z.string().min(1),
  }),
]);
 
// TypeScript knows the exact shape based on "channel"
type Notification = z.infer<typeof notificationSchema>;

Pattern 2: Schema Composition with .pipe()

Transform and validate in stages:

// Parse a comma-separated string into a validated array
const tagsSchema = z
  .string()
  .transform((val) => val.split(",").map((s) => s.trim()))
  .pipe(z.array(z.string().min(1).max(50)).min(1).max(10));
 
tagsSchema.parse("react, nextjs, typescript");
// Result: ["react", "nextjs", "typescript"]

Pattern 3: Zod v4 JSON Schema Generation

Generate JSON Schema from your Zod schemas — perfect for API documentation or OpenAPI specs:

import { z } from "zod";
 
const userSchema = z.object({
  name: z.string().describe("The user's full name"),
  email: z.string().email().describe("Primary email address"),
  age: z.number().int().min(18).describe("Must be 18 or older"),
});
 
// Built-in in Zod v4 — no third-party library needed
const jsonSchema = z.toJSONSchema(userSchema);
console.log(JSON.stringify(jsonSchema, null, 2));

Output:

{
  "type": "object",
  "properties": {
    "name": { "type": "string", "description": "The user's full name" },
    "email": { "type": "string", "format": "email", "description": "Primary email address" },
    "age": { "type": "integer", "minimum": 18, "description": "Must be 18 or older" }
  },
  "required": ["name", "email", "age"]
}

Pattern 4: Custom Error Maps

Customize all error messages globally for your app:

z.config({
  customError: (issue) => {
    if (issue.code === "too_small" && issue.minimum === 1) {
      return { message: "This field is required" };
    }
    if (issue.code === "invalid_type" && issue.expected === "string") {
      return { message: "Please enter text" };
    }
    return { message: issue.message };
  },
});

Pattern 5: Form Metadata with z.registry()

Zod v4 introduces registries for attaching metadata to schemas — useful for auto-generating form UIs:

const formRegistry = z.registry<{
  label: string;
  placeholder?: string;
  helpText?: string;
}>();
 
const nameField = z.string().min(2);
const emailField = z.string().email();
 
formRegistry.register(nameField, {
  label: "Full Name",
  placeholder: "John Doe",
  helpText: "Enter your first and last name",
});
 
formRegistry.register(emailField, {
  label: "Email Address",
  placeholder: "john@example.com",
});
 
// Retrieve metadata for form rendering
const nameMeta = formRegistry.get(nameField);
// { label: "Full Name", placeholder: "John Doe", helpText: "..." }

Step 8: Error Handling Best Practices

Flatten Errors for Forms

Zod v4 provides .flatten() for form-friendly error shapes:

const result = contactSchema.safeParse(badData);
 
if (!result.success) {
  const flat = result.error.flatten();
 
  // flat.formErrors — array of top-level errors
  // flat.fieldErrors — { name: string[], email: string[], ... }
 
  console.log(flat.fieldErrors);
  // {
  //   name: ["Name must be at least 2 characters"],
  //   email: ["Please enter a valid email address"],
  // }
}

Format Errors for API Responses

Use .format() for nested error structures:

const formatted = result.error.format();
// {
//   name: { _errors: ["Name must be at least 2 characters"] },
//   email: { _errors: ["Please enter a valid email address"] },
// }

Type-Safe Error Handling

function handleValidation<T extends z.ZodType>(
  schema: T,
  data: unknown
): { data: z.infer<T>; errors: null } | { data: null; errors: z.inferFlattenedErrors<T> } {
  const result = schema.safeParse(data);
 
  if (result.success) {
    return { data: result.data, errors: null };
  }
 
  return { data: null, errors: result.error.flatten() };
}

Step 9: Client-Side Validation (Optional Enhancement)

While server-side validation is the source of truth, you can add client-side validation for instant feedback. Since schemas are shared, validation rules stay in sync automatically.

"use client";
 
import { useState } from "react";
import { contactSchema } from "@/lib/schemas";
import type { z } from "zod";
 
export function useFormValidation() {
  const [errors, setErrors] = useState<Record<string, string[]>>({});
 
  function validateField(name: string, value: unknown) {
    // Pick just the one field from the schema
    const fieldSchema = contactSchema.shape[name as keyof typeof contactSchema.shape];
    if (!fieldSchema) return;
 
    const result = fieldSchema.safeParse(value);
    setErrors((prev) => ({
      ...prev,
      [name]: result.success ? [] : result.error.issues.map((i) => i.message),
    }));
  }
 
  function clearErrors() {
    setErrors({});
  }
 
  return { errors, validateField, clearErrors };
}

Use it in your form for real-time validation on blur:

const { errors, validateField } = useFormValidation();
 
<input
  name="email"
  onBlur={(e) => validateField("email", e.target.value)}
/>
{errors.email?.length > 0 && (
  <p className="text-red-600">{errors.email[0]}</p>
)}

Step 10: Testing Your Schemas

Schemas are pure functions — they are the easiest part of your app to test.

// __tests__/schemas.test.ts
import { describe, it, expect } from "vitest";
import { contactSchema, contactQuerySchema } from "@/lib/schemas";
 
describe("contactSchema", () => {
  const validContact = {
    name: "Jane Smith",
    email: "jane@example.com",
    message: "This is a test message for the contact form.",
    priority: "medium" as const,
  };
 
  it("accepts valid contact data", () => {
    const result = contactSchema.safeParse(validContact);
    expect(result.success).toBe(true);
  });
 
  it("rejects empty name", () => {
    const result = contactSchema.safeParse({ ...validContact, name: "" });
    expect(result.success).toBe(false);
    if (!result.success) {
      expect(result.error.issues[0].message).toContain("at least 2");
    }
  });
 
  it("rejects invalid email", () => {
    const result = contactSchema.safeParse({
      ...validContact,
      email: "not-an-email",
    });
    expect(result.success).toBe(false);
  });
 
  it("trims and lowercases email", () => {
    const result = contactSchema.safeParse({
      ...validContact,
      email: "  JANE@Example.COM  ",
    });
    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data.email).toBe("jane@example.com");
    }
  });
 
  it("accepts optional phone", () => {
    const result = contactSchema.safeParse({
      ...validContact,
      phone: "+1 555-123-4567",
    });
    expect(result.success).toBe(true);
  });
 
  it("rejects invalid priority", () => {
    const result = contactSchema.safeParse({
      ...validContact,
      priority: "critical",
    });
    expect(result.success).toBe(false);
  });
});
 
describe("contactQuerySchema", () => {
  it("applies defaults for missing params", () => {
    const result = contactQuerySchema.safeParse({});
    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data.page).toBe(1);
      expect(result.data.limit).toBe(20);
      expect(result.data.sort).toBe("createdAt");
      expect(result.data.order).toBe("desc");
    }
  });
 
  it("coerces string numbers", () => {
    const result = contactQuerySchema.safeParse({
      page: "3",
      limit: "50",
    });
    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data.page).toBe(3);
      expect(result.data.limit).toBe(50);
    }
  });
 
  it("rejects negative page numbers", () => {
    const result = contactQuerySchema.safeParse({ page: "-1" });
    expect(result.success).toBe(false);
  });
});

Run tests:

npx vitest run --reporter=verbose

Troubleshooting

"Cannot find module 'zod'"

Make sure you installed Zod v4 specifically:

npm install zod@^4

"Type 'ZodObject' is not assignable..."

Zod v4 has different type exports. If upgrading from v3, update your imports:

// v3 (old)
import { ZodType, ZodSchema } from "zod";
 
// v4 (new) — use z.ZodType directly
import { z } from "zod";
type Schema = z.ZodType;

Form data returns null

FormData.get() returns string | File | null. Zod handles null by rejecting it as an invalid type, which gives proper error messages. No need for manual null checks.

Server Action type errors

Ensure your action signature matches what useActionState expects:

// Correct signature for useActionState
async function myAction(
  prevState: ActionState,  // previous state
  formData: FormData       // form data
): Promise<ActionState>    // must return same state type

Next Steps

Now that you have mastered Zod v4 validation in Next.js, consider:

  • Add database integration — Use Zod with Drizzle ORM for end-to-end type safety from form to database
  • Build type-safe APIs — Combine Zod with tRPC for fully typed API layers
  • Implement authentication — Validate auth forms with Better Auth
  • Explore Zod v4 registries — Build auto-generated form UIs from schema metadata
  • Generate OpenAPI specs — Use z.toJSONSchema() to auto-document your APIs

Conclusion

Zod v4 is the validation layer every Next.js app needs. By defining schemas once and sharing them between client and server, you get:

  • Type safety — TypeScript types derived from schemas, never out of sync
  • Security — Every input validated before processing
  • Developer experience — IntelliSense, autocompletion, and compile-time checks
  • User experience — Clear, specific error messages on every field
  • Performance — 2-7x faster parsing and 77% smaller bundle than Zod v3

The "define once, validate everywhere" pattern eliminates entire categories of bugs. Your forms, APIs, Server Actions, and environment variables all share the same source of truth. When you change a validation rule, it updates everywhere automatically.

Start with z.object() and safeParse(). That is all you need to make your Next.js app bulletproof.


Want to read more tutorials? Check out our latest tutorial on Build Production Background Jobs with Trigger.dev v3 and Next.js.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles

Build End-to-End Type-Safe APIs with tRPC and Next.js App Router

Learn how to build fully type-safe APIs with tRPC and Next.js 15 App Router. This hands-on tutorial covers router setup, procedures, middleware, React Query integration, and server-side calls — all without writing a single API schema.

28 min read·