Input Validation with Zod
Step 28 of 31 — Next.js Tutorial Series | Source code for this step
Commands in This Step
| Command | Purpose |
|---|---|
pnpm add zod | Install the Zod validation library |
What You Will Build
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:
- No type safety — validation and TypeScript types can drift apart
- Duplicated logic — the same checks are repeated in multiple places
- No structured errors — each check returns a plain string, not field-level feedback
- Easy to miss edge cases — no email format check, no password length requirement
In this step, you will:
- Install Zod — the most popular TypeScript-first validation library
- Define reusable schemas for posts and registration
- Refactor server actions to validate with Zod and return field-level errors
- Refactor the registration API route to validate with Zod
- 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
- Why Zod?
- Step 1 — Install Zod
- Step 2 — Define Validation Schemas
- Step 3 — Refactor createPostWithState
- Step 4 — Update the Create Post Form for Field Errors
- Step 5 — Refactor updatePost
- Step 6 — Refactor the Registration API Route
- Zod Concepts Cheat Sheet
- Verify Everything Works
- Summary & Key Takeaways
Why Zod?
| Feature | Manual if checks | Zod |
|---|---|---|
| Type inference | None — types and validation are separate | z.infer<typeof schema> auto-generates TypeScript types |
| Reusability | Copy-paste checks across files | Define a schema once, use everywhere |
| Field-level errors | One error string at a time | Returns all field errors at once |
| Edge cases | Must remember to check email format, min length, etc. | Built-in validators: .email(), .min(), .max() |
| Composability | Hard 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
pnpm add zod
That's it — Zod has zero dependencies and is TypeScript-first.
Step 2 — Define Validation Schemas
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 (replacesif (!title)checks).max(200, '...')— adds length limits we never had before.email()— validates email format (we never checked this!).optional().or(z.literal(''))— allows thenamefield to be empty or missingupdatePostSchema = 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
descriptionfield to posts later, you update one schema - You can use
z.infer<typeof createPostSchema>anywhere for the TypeScript type
Step 3 — Refactor createPostWithState
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
-
safeParse()instead ofparse()—safeParsenever 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. -
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'] } -
result.data— contains the validated AND transformed data (already trimmed by.trim()). -
State shape changed — the return type now includes
fieldErrorsalongsideerror:type State = { error?: string fieldErrors?: Record<string, string[]> } | null
Step 4 — Update the Create Post Form for Field Errors
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(orcontent) and display the first error message - The general
state?.errorstill 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
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
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
registerSchema.safeParse(body)— validates the entire request body at once- 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"] } } result.data— the validated data is already trimmed and typed- Email format check — our schema includes
.email(), which we never had before - 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) | |
|---|---|---|
| Validation | createPostSchema.safeParse(...) | registerSchema.safeParse(...) |
| On error | Return { fieldErrors } as state | Return NextResponse.json({ error }, { status: 400 }) |
| Consumer | useActionState in React | fetch() from client-side form |
The schema is the same — only the error delivery mechanism differs.
Zod Concepts Cheat Sheet
| Concept | Example | What 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.infer | z.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 aZodErroron failure.
In our app, we always use safeParse because user input is always potentially invalid.
Verify Everything Works
-
Start the dev server:
pnpm dev -
Test post creation with field errors:
- Go to
/posts/new - Remove the
requiredattribute 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"
- Go to
-
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"
- Go to
-
Test post update:
- Edit a post and clear the title → "Title is required"
-
Verify trimming works:
- Create a post with title
" My Post "→ it should be saved as"My Post"(trimmed)
- Create a post with title
Summary & Key Takeaways
| Concept | Details |
|---|---|
| Zod | TypeScript-first validation library — zero dependencies, schema-based |
safeParse | Validates without throwing — returns { success, data, error } |
flatten() | Converts Zod errors to { fieldErrors: { title: ['...'] } } |
| Centralized schemas | Define once in lib/validations.ts, import everywhere |
| Server Actions | Return { fieldErrors } as state for useActionState forms |
| API Routes | Return { error: fieldErrors } with status 400 |
.trim() | Strips whitespace before validation — replaces manual .trim() calls |
z.infer | Auto-generates TypeScript types from schemas — types stay in sync with validation |
| Schema reuse | updatePostSchema = 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.