C.W.K.
Stream
Lesson 06 of 06 · published

Zod — Type-Safe Validation Inside Actions

~12 min · zod, validation, schemas

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
FormData is loose: every field is a string or a File, and TypeScript doesn't know what fields exist. Zod is the canonical answer — declare a schema, parse incoming data, get typed objects (or typed errors).

The shape

Zod schemas describe what valid data looks like: z.object({ email: z.string().email(), age: z.coerce.number().min(18) }). You call .parse() (throws on failure) or .safeParse() (returns a discriminated union of success/failure). Inside an Action, .safeParse lets you return validation errors as part of state.

FormData → typed object

Zod doesn't know about FormData natively, but you can extract fields into a plain object first: Object.fromEntries(formData). Then run safeParse. The result is either { success: true, data } with everything typed, or { success: false, error } with field-level error messages.

Error shape

Zod errors group by field: error.flatten().fieldErrors gives { email: ['invalid email'], age: ['must be a number'] }. Return this object as part of state and your form can highlight each field individually.

Where it composes with the Actions story

Your action becomes: parse → if invalid, return errors; if valid, do the work, return success. The component renders error messages from state directly. No separate validation library, no manual field iteration — Zod gives you typed input and typed errors in one call.

Validation is the contract. A Zod schema is the only place that says 'an email is required, must be lowercase, max 255 chars.' Server and client share the same schema (single source). TypeScript infers the parsed type from the schema, so the rest of your code gets autocomplete and type safety for free.

Code

Zod-validated subscribe action·tsx
import { z } from "zod";
import { useActionState } from "react";

const SubscribeSchema = z.object({
  email: z.string().email("Enter a valid email"),
  name: z.string().min(1, "Name is required"),
});

type SubscribeState = {
  email: string;
  name: string;
  errors: {
    email?: string[];
    name?: string[];
    _form?: string[];
  };
  submitted: boolean;
};

const initial: SubscribeState = { email: "", name: "", errors: {}, submitted: false };

async function subscribe(_prev: SubscribeState, fd: FormData): Promise<SubscribeState> {
  const raw = Object.fromEntries(fd) as { email?: string; name?: string };
  const result = SubscribeSchema.safeParse(raw);
  if (!result.success) {
    return {
      email: raw.email ?? "",
      name: raw.name ?? "",
      errors: result.error.flatten().fieldErrors,
      submitted: false,
    };
  }
  // result.data is typed: { email: string; name: string }
  try {
    await fetch("/api/subscribe", { method: "POST", body: JSON.stringify(result.data) });
    return { ...result.data, errors: {}, submitted: true };
  } catch (e) {
    return {
      ...result.data,
      errors: { _form: [(e as Error).message] },
      submitted: false,
    };
  }
}

export function SubscribeForm() {
  const [state, formAction] = useActionState(subscribe, initial);
  return (
    <form action={formAction} className="space-y-3 max-w-md">
      <Field name="name" label="Name" defaultValue={state.name} errors={state.errors.name} />
      <Field name="email" label="Email" defaultValue={state.email} errors={state.errors.email} />
      {state.errors._form && <p className="text-danger text-sm">{state.errors._form[0]}</p>}
      {state.submitted && <p className="text-success text-sm">Subscribed!</p>}
      <SubmitButton>Subscribe</SubmitButton>
    </form>
  );
}

function Field({ name, label, defaultValue, errors }: {
  name: string;
  label: string;
  defaultValue: string;
  errors?: string[];
}) {
  return (
    <label className="block">
      <span className="text-sm text-muted">{label}</span>
      <input name={name} defaultValue={defaultValue} className="w-full mt-1 px-3 py-2 border" />
      {errors && <p className="text-danger text-xs mt-1">{errors[0]}</p>}
    </label>
  );
}

External links

Exercise

Replace your manual email regex from the subscribe-form exercise with a Zod schema. Add a name field (required, min length 2) and a referralCode (optional, format [A-Z]{4}-\d{4}). Render per-field errors using the flatten().fieldErrors shape. Confirm: empty submit shows two field errors, malformed email shows email-specific error, valid submit succeeds.
Hint
For the referral code: z.string().regex(/^[A-Z]{4}-\d{4}$/).optional(). The .optional() makes it acceptable to be empty/missing while still validating the format when provided.

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.