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.

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.