Skip to content
C.W.K.
Stream
Lesson 06 of 08 · published

Validation with Zod

~22 min · validation, Zod, security

Level 0Curious
0 XP0/68 lessons0/11 achievements
0/120 XP to next level120 XP to go0% complete

Validate on the server, always

Client validation is a UX nicety. Server validation is the security boundary. Anyone can POST to the action endpoint; you can never trust the client to have done the check.

Zod fits cleanly

Define a schema, call safeParse on the form input, branch on success/failure. On failure, return field-level errors via useActionState; on success, run the mutation and revalidate.

Type the state shape

Make the action's State type explicit. Front-end and back-end agree on the shape via TypeScript.

Use Zod first to convert transport strings into domain-shaped input, then enforce stateful rules with the database. Missing values, formats, and ranges fit the schema; uniqueness, inventory, and concurrent ownership belong in constraints and transactions. A successful schema parse is not a safe write. Data can change between validation and mutation. Likewise, a database error is a poor field message. Let each layer protect the invariant it can actually see.

POST missing, malformed, oversized, and duplicate values directly to the action. Field errors should connect to labels and focus, no failing case should mutate or invalidate, and concurrent writes should still be stopped by database guarantees.

Code

Server Action with Zod validation·ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';

const CreatePost = z.object({
  title: z.string().min(1, 'Title is required').max(100),
  content: z.string().min(10, 'Content must be at least 10 characters'),
  email: z.string().email('Invalid email address'),
});

export type State = {
  errors?: { title?: string[]; content?: string[]; email?: string[] };
  message?: string;
};

export async function createPost(prev: State, formData: FormData): Promise<State> {
  const parsed = CreatePost.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
    email: formData.get('email'),
  });

  if (!parsed.success) {
    return {
      errors: parsed.error.flatten().fieldErrors,
      message: 'Validation failed',
    };
  }

  await db.post.create({ data: parsed.data });
  revalidatePath('/posts');
  return { message: 'Post created' };
}
Render field errors from state·tsx
'use client';
import { useActionState } from 'react';
import { createPost, type State } from '@/app/actions';

export function PostForm() {
  const [state, action, pending] = useActionState<State, FormData>(createPost, {});
  return (
    <form action={action} className="space-y-3">
      <div>
        <input name="title" />
        {state.errors?.title?.map(e => <p key={e} className="text-red-500 text-sm">{e}</p>)}
      </div>
      <div>
        <textarea name="content" />
        {state.errors?.content?.map(e => <p key={e} className="text-red-500 text-sm">{e}</p>)}
      </div>
      <button disabled={pending}>{pending ? 'Saving&hellip;' : 'Create'}</button>
      {state.message && <p>{state.message}</p>}
    </form>
  );
}

External links

Exercise

Add Zod validation to one of your existing Server Actions. Render at least three field-level errors via useActionState. Submit invalid input and confirm the action never touches the database.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.