Input Validation with Zod

Step 28 of 31Next.js Tutorial Series | Source code for this step

Live Demo →


Commands in This Step

CommandPurpose
pnpm add zodInstall the Zod validation library

What You Will Build

↑ Index

Right now, our app validates user input with ad-hoc if checks scattered across server actions and API routes:

// In createPostWithState:
if (!title?.trim()) {
  return { error: 'Title is required' }
}

// In register route:
if (!body.email || !body.password) {
  return NextResponse.json({ error: 'Email and password are required' }, { status: 400 })
}

This approach has problems:

  1. No type safety — validation and TypeScript types can drift apart
  2. Duplicated logic — the same checks are repeated in multiple places
  3. No structured errors — each check returns a plain string, not field-level feedback
  4. Easy to miss edge cases — no email format check, no password length requirement

In this step, you will:

  1. Install Zod — the most popular TypeScript-first validation library
  2. Define reusable schemas for posts and registration
  3. Refactor server actions to validate with Zod and return field-level errors
  4. Refactor the registration API route to validate with Zod
  5. Update the create post form to display field-level errors

Goal: Learn how to centralize validation with Zod schemas and integrate them cleanly into Next.js server actions and API routes.


Table of Contents

  1. Why Zod?
  2. Step 1 — Install Zod
  3. Step 2 — Define Validation Schemas
  4. Step 3 — Refactor createPostWithState
  5. Step 4 — Update the Create Post Form for Field Errors
  6. Step 5 — Refactor updatePost
  7. Step 6 — Refactor the Registration API Route
  8. Zod Concepts Cheat Sheet
  9. Verify Everything Works
  10. Summary & Key Takeaways

Why Zod?

↑ Index

FeatureManual if checksZod
Type inferenceNone — types and validation are separatez.infer<typeof schema> auto-generates TypeScript types
ReusabilityCopy-paste checks across filesDefine a schema once, use everywhere
Field-level errorsOne error string at a timeReturns all field errors at once
Edge casesMust remember to check email format, min length, etc.Built-in validators: .email(), .min(), .max()
ComposabilityHard to combine.extend(), .pick(), .merge() schemas together

Zod is the de facto standard for validation in the Next.js ecosystem. The Next.js docs themselves recommend it for server action validation.


Step 1 — Install Zod

↑ Index

pnpm add zod

That's it — Zod has zero dependencies and is TypeScript-first.


Step 2 — Define Validation Schemas

↑ Index

Create a new file lib/validations.ts to hold all schemas in one place:

import { z } from 'zod'

// --- Post schemas ---

export const createPostSchema = z.object({
  title: z
    .string()
    .trim()
    .min(1, 'Title is required')
    .max(200, 'Title must be 200 characters or less'),
  content: z
    .string()
    .trim()
    .min(1, 'Content is required')
    .max(10000, 'Content must be 10,000 characters or less'),
})

export const updatePostSchema = createPostSchema

// --- Auth schemas ---

export const registerSchema = z.object({
  name: z
    .string()
    .trim()
    .max(100, 'Name must be 100 characters or less')
    .optional()
    .or(z.literal('')),
  email: z
    .string()
    .trim()
    .min(1, 'Email is required')
    .email('Please enter a valid email address'),
  password: z
    .string()
    .min(8, 'Password must be at least 8 characters'),
})

What is happening here

  • z.string().trim() — strips whitespace before validating (replaces our manual .trim() calls)
  • .min(1, '...') — ensures the field is not empty (replaces if (!title) checks)
  • .max(200, '...') — adds length limits we never had before
  • .email() — validates email format (we never checked this!)
  • .optional().or(z.literal('')) — allows the name field to be empty or missing
  • updatePostSchema = createPostSchema — same validation for create and update (DRY)

Why lib/validations.ts?

Keeping schemas in one file means:

  • Server actions import from the same place as API routes
  • If you add a description field to posts later, you update one schema
  • You can use z.infer<typeof createPostSchema> anywhere for the TypeScript type

Step 3 — Refactor createPostWithState

↑ Index

Currently createPostWithState in app/actions.ts does manual validation:

// Before — manual checks
const title = formData.get('title') as string
const content = formData.get('content') as string

if (!title?.trim()) {
  return { error: 'Title is required' }
}

if (!content?.trim()) {
  return { error: 'Content is required' }
}

Replace the manual checks with Zod. Update createPostWithState in app/actions.ts:

import { createPostSchema } from '@/lib/validations'

export async function createPostWithState(
  prevState: { error?: string; fieldErrors?: Record<string, string[]> } | null,
  formData: FormData,
) {
  const session = await auth()

  if (!session?.user?.email) {
    return { error: 'You must be signed in to create a post' }
  }

  const result = createPostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  })

  if (!result.success) {
    return {
      fieldErrors: result.error.flatten().fieldErrors as Record<string, string[]>,
    }
  }

  const { title, content } = result.data

  const user = await prisma.user.findUnique({
    where: { email: session.user.email },
  })

  if (!user) {
    return { error: 'User not found' }
  }

  let post
  try {
    post = await prisma.post.create({
      data: { title, content, authorId: user.id },
    })
  } catch {
    return { error: 'Failed to create post. Please try again.' }
  }

  redirect(`/posts/${post.id}`)
}

What changed

  1. safeParse() instead of parse()safeParse never throws. It returns { success: true, data } or { success: false, error }. This is the right choice for server actions where we want to return errors to the UI.

  2. result.error.flatten().fieldErrors — transforms Zod's error object into a simple format:

    { title: ['Title is required'], content: ['Content must be 10,000 characters or less'] }
    
  3. result.data — contains the validated AND transformed data (already trimmed by .trim()).

  4. State shape changed — the return type now includes fieldErrors alongside error:

    type State = {
      error?: string
      fieldErrors?: Record<string, string[]>
    } | null
    

Step 4 — Update the Create Post Form for Field Errors

↑ Index

Now we need to display field-level errors in the form. Update app/posts/new/CreatePostForm.tsx:

'use client'

import { useActionState } from 'react'
import { createPostWithState } from '@/app/actions'
import SubmitButton from './SubmitButton'

export default function CreatePostForm() {
  const [state, formAction] = useActionState(createPostWithState, null)

  return (
    <form action={formAction} className="rounded-lg bg-white p-6 shadow-md">
      <div className="space-y-4">
        {state?.error && (
          <div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
            {state.error}
          </div>
        )}

        <div>
          <label
            htmlFor="title"
            className="mb-1 block text-sm font-medium text-gray-700"
          >
            Title
          </label>
          <input
            id="title"
            name="title"
            type="text"
            required
            className="w-full rounded border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-teal-500"
            placeholder="Your post title"
          />
          {state?.fieldErrors?.title && (
            <p className="mt-1 text-sm text-red-600">{state.fieldErrors.title[0]}</p>
          )}
        </div>

        <div>
          <label
            htmlFor="content"
            className="mb-1 block text-sm font-medium text-gray-700"
          >
            Content
          </label>
          <textarea
            id="content"
            name="content"
            required
            rows={8}
            className="w-full rounded border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-teal-500"
            placeholder="Write your post content..."
          />
          {state?.fieldErrors?.content && (
            <p className="mt-1 text-sm text-red-600">{state.fieldErrors.content[0]}</p>
          )}
        </div>

        <SubmitButton />
      </div>
    </form>
  )
}

What changed

  • After each input, we check state?.fieldErrors?.title (or content) and display the first error message
  • The general state?.error still appears at the top for non-field errors (auth failures, database errors)
  • We access [0] because Zod can return multiple errors per field — we show only the first

Step 5 — Refactor updatePost

↑ Index

The updatePost action in app/actions.ts has the same manual validation. Replace it with Zod:

import { updatePostSchema } from '@/lib/validations'

export async function updatePost(postId: number, formData: FormData) {
  const session = await auth()

  if (!session?.user?.email) {
    throw new Error('You must be signed in to edit a post')
  }

  const post = await prisma.post.findUnique({
    where: { id: postId },
    include: { author: true },
  })

  if (!post) {
    throw new Error('Post not found')
  }

  if (post.author?.email !== session.user.email) {
    throw new Error('You can only edit your own posts')
  }

  const result = updatePostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  })

  if (!result.success) {
    const firstError = Object.values(result.error.flatten().fieldErrors)[0]?.[0]
    throw new Error(firstError || 'Invalid input')
  }

  const { title, content } = result.data

  await prisma.post.update({
    where: { id: postId },
    data: { title, content },
  })

  redirect(`/posts/${postId}`)
}

Why throw here instead of returning state?

updatePost uses the simple <form action={fn}> pattern (without useActionState), so it throws on error instead of returning state. The Zod validation still gives us:

  • Proper trimming (via .trim())
  • Length limits
  • A clean error message from the schema

If you later refactor the edit form to use useActionState, you can switch to safeParse + returning fieldErrors — the same pattern as createPostWithState.


Step 6 — Refactor the Registration API Route

↑ Index

The registration API route at app/api/auth/register/route.ts also validates manually. Update it to use the Zod schema:

import prisma from '@/lib/prisma'
import bcrypt from 'bcryptjs'
import { NextResponse } from 'next/server'
import { registerSchema } from '@/lib/validations'

export async function POST(request: Request) {
  try {
    const body = await request.json()

    const result = registerSchema.safeParse(body)

    if (!result.success) {
      return NextResponse.json(
        { error: result.error.flatten().fieldErrors },
        { status: 400 },
      )
    }

    const { email, password, name } = result.data

    const existingUser = await prisma.user.findUnique({
      where: { email },
    })

    if (existingUser) {
      return NextResponse.json(
        { error: 'A user with this email already exists' },
        { status: 409 },
      )
    }

    const hashedPassword = await bcrypt.hash(password, 10)

    const user = await prisma.user.create({
      data: {
        email,
        name: name || null,
        password: hashedPassword,
      },
    })

    return NextResponse.json(
      { id: user.id, email: user.email, name: user.name },
      { status: 201 },
    )
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to create user' },
      { status: 500 },
    )
  }
}

What changed

  1. registerSchema.safeParse(body) — validates the entire request body at once
  2. Field-level errors — on validation failure, the API returns structured errors:
    { "error": { "email": ["Please enter a valid email address"], "password": ["Password must be at least 8 characters"] } }
    
  3. result.data — the validated data is already trimmed and typed
  4. Email format check — our schema includes .email(), which we never had before
  5. Password length.min(8) enforces a minimum length we never checked

API route vs Server Action — same schema, different error pattern

Server Action (createPostWithState)API Route (register)
ValidationcreatePostSchema.safeParse(...)registerSchema.safeParse(...)
On errorReturn { fieldErrors } as stateReturn NextResponse.json({ error }, { status: 400 })
ConsumeruseActionState in Reactfetch() from client-side form

The schema is the same — only the error delivery mechanism differs.


Zod Concepts Cheat Sheet

↑ Index

ConceptExampleWhat it does
z.string()z.string()Validates the value is a string
.trim()z.string().trim()Strips leading/trailing whitespace
.min(n)z.string().min(1, 'Required')Minimum length with custom message
.max(n)z.string().max(200, 'Too long')Maximum length
.email()z.string().email('Invalid email')Validates email format
.optional()z.string().optional()Field can be undefined
z.object()z.object({ title: z.string() })Validates an object shape
.safeParse()schema.safeParse(data)Validates without throwing
.parse()schema.parse(data)Validates or throws ZodError
.flatten()error.flatten()Converts errors to { fieldErrors, formErrors }
z.inferz.infer<typeof schema>Extracts the TypeScript type from a schema

When to use safeParse vs parse

  • safeParse — when you want to handle errors gracefully (server actions, API routes). Returns { success, data, error }.
  • parse — when invalid data is truly unexpected (middleware, internal functions). Throws a ZodError on failure.

In our app, we always use safeParse because user input is always potentially invalid.


Verify Everything Works

↑ Index

  1. Start the dev server: pnpm dev

  2. Test post creation with field errors:

    • Go to /posts/new
    • Remove the required attribute from the title input in DevTools
    • Submit the form with an empty title → you should see "Title is required" below the title field
    • Submit with a very long title (paste 201+ characters) → "Title must be 200 characters or less"
  3. Test registration validation:

    • Go to /register
    • Enter an invalid email like notanemail → the API returns a validation error
    • Enter a short password (less than 8 characters) → "Password must be at least 8 characters"
  4. Test post update:

    • Edit a post and clear the title → "Title is required"
  5. Verify trimming works:

    • Create a post with title " My Post " → it should be saved as "My Post" (trimmed)

Summary & Key Takeaways

↑ Index

ConceptDetails
ZodTypeScript-first validation library — zero dependencies, schema-based
safeParseValidates without throwing — returns { success, data, error }
flatten()Converts Zod errors to { fieldErrors: { title: ['...'] } }
Centralized schemasDefine once in lib/validations.ts, import everywhere
Server ActionsReturn { fieldErrors } as state for useActionState forms
API RoutesReturn { error: fieldErrors } with status 400
.trim()Strips whitespace before validation — replaces manual .trim() calls
z.inferAuto-generates TypeScript types from schemas — types stay in sync with validation
Schema reuseupdatePostSchema = createPostSchema — same validation, zero duplication

What is Next

In Step 29, we will add author avatars using the next/image component — learning how Next.js automatically optimizes, resizes, and lazy-loads images for better performance.