Type something to search...
Form Validation in Next.js with Zod and Server Actions

Form Validation in Next.js with Zod and Server Actions

Server Actions make it easy to accept a form submission, but they hand you a FormData object full of strings, nulls, and the occasional File. Before any of that touches your database, you need to answer a few questions. Is the email actually an email? Is the seat count a whole number? Did the user tick the terms checkbox? And if something is wrong, how do you show a helpful message next to the right field without wiping everything the user typed?

Zod is the most common answer to the first set of questions in the TypeScript world, and React's useActionState hook handles the second. Together they give you a validation flow that runs on the server, where it can't be bypassed, while still feeling like a polished client-side form.

This guide builds a registration form step by step. It covers defining a schema, turning FormData into something Zod can check, returning field-level errors, keeping the user's input after a failed submission, server-only checks like "email already taken", and file validation. The examples use Zod 4 and Next.js 16.

Why Validation Belongs on the Server

HTML attributes like required, type="email", and minLength are worth adding. They give instant feedback and cost nothing. But they're a convenience, not a guarantee. Anyone can open DevTools and remove them, or skip your form entirely and send a POST request straight to the Server Action.

That last point is easy to forget. A Server Action is a public endpoint. Its ID is in your client bundle, and Next.js will run it for any well-formed request from the same origin. So the rule is simple: client validation is for user experience, server validation is for correctness. You always need the second one.

Installing Zod

npm install zod

Zod 4 is the current major version. A few APIs changed from Zod 3, and the examples below use the new forms: top-level format validators like z.email(), the error parameter for custom messages, and z.flattenError() for turning errors into a per-field object.

Defining the Schema

Put the schema in its own module so both the server and, optionally, the client can import it. It shouldn't import anything server-only.

// lib/schemas/registration.ts
import { z } from "zod";

export const PLANS = ["starter", "team", "enterprise"] as const;

export const registrationSchema = z
  .object({
    name: z
      .string()
      .trim()
      .min(2, { error: "Name must be at least 2 characters." })
      .max(80, { error: "Name must be 80 characters or fewer." }),
    email: z
      .email({ error: "Enter a valid email address." })
      .transform((value) => value.toLowerCase()),
    password: z
      .string()
      .min(10, { error: "Password must be at least 10 characters." }),
    confirmPassword: z.string(),
    plan: z.enum(PLANS, { error: "Choose a plan." }),
    seats: z.coerce
      .number({ error: "Seats must be a number." })
      .int({ error: "Seats must be a whole number." })
      .min(1, { error: "You need at least 1 seat." })
      .max(500, { error: "Contact sales for more than 500 seats." }),
    terms: z.literal(true, {
      error: "You must accept the terms to continue.",
    }),
  })
  .refine((data) => data.password === data.confirmPassword, {
    error: "Passwords don't match.",
    path: ["confirmPassword"],
  });

export type RegistrationInput = z.infer<typeof registrationSchema>;

A few things in this schema are specific to form data.

  • trim() before length checks. Users paste names with trailing spaces. Trimming first means " " doesn't count as a valid name.
  • z.coerce.number() for numeric inputs. Every value in FormData is a string, even from input type="number". Coercion converts "12" to 12 before the int and min checks run. One catch: an empty string coerces to 0, so a min(1) check is what actually catches a blank field here.
  • z.literal(true) for a required checkbox. You'll convert the checkbox to a boolean before parsing (shown next), and then the schema only accepts true.
  • refine with a path. Cross-field rules like "passwords match" run on the whole object. Setting path attaches the error to confirmPassword, so it shows next to the right input.

z.infer gives you a TypeScript type for the parsed result, so the code that receives validated data is fully typed.

Turning FormData into an Object

Zod validates plain objects, so you need to pull values out of FormData first. It's tempting to write Object.fromEntries(formData), but that grabs every field, including internal $ACTION_ keys Next.js adds, and it collapses repeated fields into one value. Picking fields explicitly is clearer and safer:

// app/register/parse.ts
export function registrationFromFormData(formData: FormData) {
  return {
    name: formData.get("name"),
    email: formData.get("email"),
    password: formData.get("password"),
    confirmPassword: formData.get("confirmPassword"),
    plan: formData.get("plan"),
    seats: formData.get("seats"),
    terms: formData.get("terms") === "on",
  };
}

The values stay as FormDataEntryValue | null. That's fine, because the schema decides what's valid. A missing field arrives as null and fails the z.string() check with a clear error instead of crashing.

The checkbox is the one field converted by hand. Browsers send "on" for a checked checkbox and omit it entirely when unchecked, so comparing to "on" gives a reliable boolean.

The Server Action

Now the action. It accepts the previous state and the form data (the signature useActionState expects), validates, and returns a state object describing what happened:

// app/register/actions.ts
"use server";

import { redirect } from "next/navigation";
import { z } from "zod";
import { registrationSchema } from "@/lib/schemas/registration";
import { createAccount, findUserByEmail } from "@/lib/users";
import { registrationFromFormData } from "./parse";

export type RegistrationState = {
  status: "idle" | "error";
  message?: string;
  fieldErrors?: Partial<Record<string, string[]>>;
  values?: {
    name?: string;
    email?: string;
    plan?: string;
    seats?: string;
  };
};

export async function register(
  _prevState: RegistrationState,
  formData: FormData,
): Promise<RegistrationState> {
  const raw = registrationFromFormData(formData);

  // Echo back safe values so the form can repopulate after an error.
  const values = {
    name: String(raw.name ?? ""),
    email: String(raw.email ?? ""),
    plan: String(raw.plan ?? ""),
    seats: String(raw.seats ?? ""),
  };

  const result = registrationSchema.safeParse(raw);

  if (!result.success) {
    return {
      status: "error",
      message: "Please fix the highlighted fields.",
      fieldErrors: z.flattenError(result.error).fieldErrors,
      values,
    };
  }

  const { name, email, password, plan, seats } = result.data;

  if (await findUserByEmail(email)) {
    return {
      status: "error",
      fieldErrors: { email: ["An account with this email already exists."] },
      values,
    };
  }

  await createAccount({ name, email, password, plan, seats });
  redirect("/welcome");
}

Walking through it:

  1. safeParse instead of parse. parse throws on invalid input; safeParse returns { success, data } or { success, error }. Validation failures are expected, so they shouldn't be exceptions.
  2. z.flattenError(result.error).fieldErrors turns Zod's list of issues into an object keyed by field name, where each value is an array of messages, for example { email: ["Enter a valid email address."] }. That shape maps directly onto form inputs.
  3. Business rules run after schema validation. "Email already registered" can't be expressed in a schema because it needs the database. Check it once the shape is valid, and return an error in the same format so the UI doesn't care where it came from.
  4. values echoes the input back, minus the passwords. You'll see why in a moment.
  5. redirect on success. It throws a special control-flow error, so nothing after it runs. Keep it outside any try/catch.

Here findUserByEmail and createAccount stand in for your data layer. Hash the password inside createAccount with a library such as bcrypt or argon2; never store it as-is.

Why the state type is loose

fieldErrors is typed as Partial<Record<string, string[]>> rather than the exact per-field type Zod infers. The state is shared between schema errors and hand-written errors like "email taken", and the looser type keeps both paths simple. If you want stricter keys, derive the type from RegistrationInput and build manual errors to match it.

The Form Component

The form is a Client Component so it can use useActionState:

// app/register/register-form.tsx
"use client";

import { useActionState } from "react";
import { register, type RegistrationState } from "./actions";

const initialState: RegistrationState = { status: "idle" };

export function RegisterForm() {
  const [state, formAction, pending] = useActionState(register, initialState);
  const errors = state.fieldErrors ?? {};

  return (
    <form action={formAction} noValidate>
      {state.message && (
        <p role="alert" className="form-message">
          {state.message}
        </p>
      )}

      <Field label="Name" name="name" errors={errors.name}>
        <input
          id="name"
          name="name"
          defaultValue={state.values?.name}
          aria-invalid={Boolean(errors.name)}
          aria-describedby={errors.name ? "name-error" : undefined}
        />
      </Field>

      <Field label="Email" name="email" errors={errors.email}>
        <input
          id="email"
          name="email"
          type="email"
          defaultValue={state.values?.email}
          aria-invalid={Boolean(errors.email)}
          aria-describedby={errors.email ? "email-error" : undefined}
        />
      </Field>

      <Field label="Password" name="password" errors={errors.password}>
        <input
          id="password"
          name="password"
          type="password"
          aria-invalid={Boolean(errors.password)}
          aria-describedby={errors.password ? "password-error" : undefined}
        />
      </Field>

      <Field
        label="Confirm password"
        name="confirmPassword"
        errors={errors.confirmPassword}
      >
        <input
          id="confirmPassword"
          name="confirmPassword"
          type="password"
          aria-invalid={Boolean(errors.confirmPassword)}
          aria-describedby={
            errors.confirmPassword ? "confirmPassword-error" : undefined
          }
        />
      </Field>

      <Field label="Plan" name="plan" errors={errors.plan}>
        <select id="plan" name="plan" defaultValue={state.values?.plan ?? ""}>
          <option value="" disabled>
            Choose a plan
          </option>
          <option value="starter">Starter</option>
          <option value="team">Team</option>
          <option value="enterprise">Enterprise</option>
        </select>
      </Field>

      <Field label="Seats" name="seats" errors={errors.seats}>
        <input
          id="seats"
          name="seats"
          type="number"
          min={1}
          defaultValue={state.values?.seats ?? "1"}
        />
      </Field>

      <Field label="" name="terms" errors={errors.terms}>
        <label>
          <input type="checkbox" name="terms" /> I accept the terms
        </label>
      </Field>

      <button type="submit" disabled={pending}>
        {pending ? "Creating account..." : "Create account"}
      </button>
    </form>
  );
}

function Field({
  label,
  name,
  errors,
  children,
}: {
  label: string;
  name: string;
  errors?: string[];
  children: React.ReactNode;
}) {
  return (
    <div className="field">
      {label && <label htmlFor={name}>{label}</label>}
      {children}
      {errors?.map((error) => (
        <p key={error} id={`${name}-error`} className="field-error">
          {error}
        </p>
      ))}
    </div>
  );
}

The page that renders it is just a Server Component:

// app/register/page.tsx
import { RegisterForm } from "./register-form";

export default function RegisterPage() {
  return (
    <main>
      <h1>Create your account</h1>
      <RegisterForm />
    </main>
  );
}

Keeping input after an error

React resets uncontrolled form fields after a form action finishes. That's great after a successful submit, but frustrating after a validation error: the user mistypes their email and every field is cleared.

The values object returned by the action solves it. After the reset, each input falls back to its defaultValue, and defaultValue now comes from state.values, which holds what the user just submitted. The fields appear to keep their contents.

Passwords are deliberately left out of values. Never send a password back to the browser in the action's response; users can retype it.

Accessibility details

The form uses noValidate so the browser's native popups don't compete with your server messages, while the type="email" and min attributes still help mobile keyboards. Each invalid input gets aria-invalid and an aria-describedby pointing at its error message, so screen readers announce the error when the field is focused. The top-level message uses role="alert".

If you'd rather keep native browser validation as a first line of defense, drop noValidate and add required attributes. Server validation still runs either way.

A Reusable Parsing Helper

Once you have more than a couple of forms, the safeParse and flattenError dance gets repetitive. A small generic helper keeps actions short:

// lib/validation.ts
import { z } from "zod";

type ParseResult<T> =
  | { success: true; data: T }
  | { success: false; fieldErrors: Partial<Record<string, string[]>> };

export function parseWithSchema<S extends z.ZodType>(
  schema: S,
  input: unknown,
): ParseResult<z.output<S>> {
  const result = schema.safeParse(input);

  if (result.success) {
    return { success: true, data: result.data };
  }

  return {
    success: false,
    fieldErrors: z.flattenError(result.error).fieldErrors as Partial<
      Record<string, string[]>
    >,
  };
}

Usage in any action:

const parsed = parseWithSchema(
  registrationSchema,
  registrationFromFormData(formData),
);
if (!parsed.success) {
  return { status: "error", fieldErrors: parsed.fieldErrors, values };
}
// parsed.data is fully typed here

Validating Other Input Types

Optional fields

An empty text input sends an empty string, not null. So z.string().optional() accepts "", which is usually what you want for a free-text field. For an optional field with a format, like an optional website URL, allow the empty string explicitly:

const website = z.union([
  z.literal(""),
  z.url({ error: "Enter a valid URL." }),
]);

Multiple values

For checkboxes that share a name, or a multi-select, use formData.getAll() and validate an array:

const raw = { topics: formData.getAll("topics") };

const schema = z.object({
  topics: z
    .array(z.enum(["news", "releases", "events"]))
    .min(1, { error: "Pick at least one topic." }),
});

Files

Zod 4 has a built-in z.file() schema with size and MIME type checks, which pairs nicely with file inputs:

// lib/schemas/avatar.ts
import { z } from "zod";

export const avatarSchema = z.object({
  avatar: z
    .file({ error: "Choose an image to upload." })
    .max(2_000_000, { error: "Images must be 2 MB or smaller." })
    .mime(["image/png", "image/jpeg", "image/webp"], {
      error: "Use a PNG, JPEG, or WebP image.",
    }),
});
// app/settings/actions.ts (excerpt)
const result = avatarSchema.safeParse({ avatar: formData.get("avatar") });

Remember that Server Actions have a 1 MB request body limit by default. To accept a 2 MB image, raise experimental.serverActions.bodySizeLimit in next.config.ts with some headroom for multipart overhead. The MIME type comes from the browser, so treat it as a hint and verify the file's contents if security depends on it.

Optional: Instant Client-Side Checks with the Same Schema

Because the schema module has no server-only imports, you can reuse it in the browser for immediate feedback before a round trip. One approach is to validate on submit and cancel the submission if it fails:

// app/register/register-form.tsx (excerpt)
import { useState } from "react";
import { z } from "zod";
import { registrationSchema } from "@/lib/schemas/registration";
import { registrationFromFormData } from "./parse";

// Inside RegisterForm:
const [clientErrors, setClientErrors] = useState<
  Partial<Record<string, string[]>>
>({});

function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
  const formData = new FormData(event.currentTarget);
  const result = registrationSchema.safeParse(
    registrationFromFormData(formData),
  );

  if (!result.success) {
    event.preventDefault();
    setClientErrors(z.flattenError(result.error).fieldErrors);
  } else {
    setClientErrors({});
  }
}

// <form action={formAction} onSubmit={handleSubmit} noValidate>

Calling preventDefault() in onSubmit stops React from running the form action. Merge clientErrors with state.fieldErrors when rendering. This adds Zod to your client bundle, so weigh that against how much the instant feedback is worth for a given form. The server check stays in place regardless.

Common Mistakes

  • Validating only on the client. The action can be called directly. Always parse on the server.
  • Trusting Object.fromEntries(formData). It includes extra keys and drops repeated values. Pick fields explicitly.
  • Using parse and letting it throw. A ZodError escaping the action ends up in your error boundary instead of next to the field.
  • Forgetting that numbers arrive as strings. Use z.coerce.number() and remember "" coerces to 0.
  • Echoing secrets. Don't put passwords or tokens in the returned state.
  • Calling redirect inside try/catch. The catch block swallows it. Redirect after the try block ends.
  • Stopping at shape validation. A valid ID can still point to a row the user doesn't own. Authorization is a separate check; the Server Actions guide covers it.

Conclusion

Server-side validation with Zod and Server Actions comes down to a short, repeatable flow: pull the fields you expect out of FormData, run them through safeParse, and return z.flattenError(...).fieldErrors along with the safe parts of the input. On the client, useActionState gives you that state plus a pending flag, so you can render messages next to each field and repopulate inputs through defaultValue.

Keep the schema in a shared module, add business-rule checks after the schema passes, and treat client-side validation as a bonus layer. If you also want the UI to update before the server answers, combine this with optimistic updates using useOptimistic.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading