Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.

The safest pattern for a Next.js 15 App Router form is layered validation: let HTML constraints provide immediate browser feedback, use React Hook Form (RHF) with a Zod resolver when rich client interactions justify it, and parse the submitted data with the same Zod schema inside the Server Action before any database or external mutation. Client validation improves usability; it is never a security boundary.

This guide uses one shared schema, an RHF-managed client flow, and a Server Action that validates again. It also explains the alternative native action/useActionState architecture so you do not accidentally imply that the two submission models combine automatically.

Choose the submission model before writing code

Next.js supports two legitimate designs. In the native design, a form’s action points to a Server Action, which receives FormData; useActionState can expose returned errors and pending state. This is the most direct path and can preserve progressive enhancement in the documented Server Component arrangement. In the RHF design, handleSubmit intercepts submission, runs client validation through zodResolver, and your client handler explicitly invokes the Server Action. That gives you granular client interaction, but it is a different flow and should not be presented as a native-action form automatically enhanced by RHF.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Concern Native action + useActionState RHF + zodResolver
Client interaction HTML constraints and server-returned state Immediate field and form errors, touched/dirty tracking, conditional fields
State ownership React action state and browser form state RHF owns client state; action response is separate server state
Progressive enhancement Documented for the relevant Server Component setup Do not assume it when submission is intercepted by handleSubmit
Complexity Less client code More setup, useful for complex interactive forms
Pending UI useActionState or descendant useFormStatus RHF’s submitting state plus an action-pending mechanism

The implementation below deliberately chooses RHF for client feedback and calls the Server Action from the client handler. If you need no client-managed complexity, remove RHF and use the native path described later.

Install and share a schema

Keep the schema in a module importable by both client and server. It must not import server-only modules. Zod schemas can have different input and output types when coercion, defaults, or transforms are used, so use z.input for values entering the form and z.output for parsed values used by your mutation.

npm install zod react-hook-form @hookform/resolvers

Create app/signup/schema.ts:

import { z } from "zod";

export const signupSchema = z.object({
  name: z.string().trim().min(2, "Name must be at least 2 characters"),
  email: z.string().trim().email("Enter a valid email address"),
  age: z.coerce.number().int().min(13, "You must be at least 13"),
  terms: z.literal("on", {
    errorMap: () => ({ message: "Accept the terms to continue" }),
  }),
});

export type SignupInput = z.input<typeof signupSchema>;
export type SignupData = z.output<typeof signupSchema>;

Here, age may arrive as a string from an input element but becomes a number after parsing. A successful safeParse returns parsed data; a failure returns a ZodError. If a refinement or transform is asynchronous, use the schema’s asynchronous parse method and make the action asynchronous all the way through.

Build the Server Action trust boundary

The action must extract only fields your schema expects, normalize browser-specific values, parse again, authorize the caller, and mutate only after successful validation. A browser can bypass RHF, alter requests, or call the action directly.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
// app/signup/actions.ts
"use server";

import { signupSchema } from "./schema";

export type SignupState = {
  ok: boolean;
  message?: string;
  fieldErrors?: Partial<Record<"name" | "email" | "age" | "terms", string[]>>;
};

export async function createSignup(
  _previousState: SignupState,
  formData: FormData,
): Promise<SignupState> {
  // Replace this with your real session and permission checks.
  const user = await getCurrentUser();
  if (!user) {
    return { ok: false, message: "You must be signed in." };
  }

  const raw = {
    name: formData.get("name"),
    email: formData.get("email"),
    age: formData.get("age"),
    terms: formData.get("terms"),
  };

  const result = signupSchema.safeParse(raw);
  if (!result.success) {
    return {
      ok: false,
      message: "Please correct the highlighted fields.",
      fieldErrors: result.error.flatten().fieldErrors,
    };
  }

  // result.data is typed SignupData (age is a number here).
  await saveSignup({
    userId: user.id,
    name: result.data.name,
    email: result.data.email,
    age: result.data.age,
  });

  return { ok: true, message: "Signup saved." };
}

// Supply these from your application; they are placeholders for your auth/data layer.
declare function getCurrentUser(): Promise<{ id: string } | null>;
declare function saveSignup(input: {
  userId: string;
  name: string;
  email: string;
  age: number;
}): Promise<void>;

Authorization belongs inside every Server Action, even when the page itself is restricted. Do not trust a hidden input for identity or permissions. Also avoid blindly passing Object.fromEntries(formData) to a schema: Next.js notes that it can contain framework-generated $ACTION_-prefixed properties. Selecting known keys makes the boundary explicit.

Connect React Hook Form to Zod

Because this component uses hooks and a client-side submit handler, mark it with "use client". The resolver runs the shared schema before the action is called.

// app/signup/SignupForm.tsx
"use client";

import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createSignup, type SignupState } from "./actions";
import { signupSchema, type SignupInput, type SignupData } from "./schema";

const initialState: SignupState = { ok: false };

export function SignupForm() {
  const [actionState, setActionState] = useState(initialState);
  const [isSubmittingAction, setIsSubmittingAction] = useState(false);
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<SignupInput, unknown, SignupData>({
    resolver: zodResolver(signupSchema),
    mode: "onBlur",
  });

  const onValid = async (values: SignupData) => {
    setIsSubmittingAction(true);
    setActionState(initialState);
    try {
      const data = new FormData();
      data.set("name", values.name);
      data.set("email", values.email);
      data.set("age", String(values.age));
      data.set("terms", "on");
      // This action has the previous-state argument first.
      const next = await createSignup(initialState, data);
      setActionState(next);
    } catch {
      setActionState({ ok: false, message: "The request failed. Try again." });
    } finally {
      setIsSubmittingAction(false);
    }
  };

  return (
    <form onSubmit={handleSubmit(onValid)} noValidate>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" autoComplete="name" {...register("name")} />
        {errors.name && <p role="alert">{errors.name.message}</p>}
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" autoComplete="email" {...register("email")} />
        {errors.email && <p role="alert">{errors.email.message}</p>}
      </div>
      <div>
        <label htmlFor="age">Age</label>
        <input id="age" type="number" min="13" {...register("age")} />
        {errors.age && <p role="alert">{errors.age.message}</p>}
      </div>
      <label>
        <input type="checkbox" {...register("terms")} />
        I accept the terms
      </label>
      {errors.terms && <p role="alert">{errors.terms.message}</p>}
      <button type="submit" disabled={isSubmitting || isSubmittingAction}>
        {isSubmitting || isSubmittingAction ? "Saving…" : "Save"}
      </button>
      {actionState.message && <p role="status">{actionState.message}</p>}
    </form>
  );
}

zodResolver can infer types from the schema. The explicit three-parameter useForm call is useful when input and output differ. The example recreates FormData from parsed values, so the server action still receives the same wire shape a native form would send. For a larger form, map fields systematically rather than relying on unchecked casts.

Use HTML constraints as the first, inexpensive layer

Keep constraints such as required, type="email", min, maxLength, and appropriate autocomplete tokens in the markup. They provide native feedback before JavaScript runs. In the RHF example, noValidate is set because RHF is displaying the messages; remove it if you want browser constraint bubbles as well. Whichever choice you make, retain the server parse.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Native Server Action alternative

If your form does not need RHF’s client state, a Server Component can submit directly with action={createSignup}. When using useActionState, the action signature changes: previous state is the first argument and FormData is second.

// app/signup/NativeSignupForm.tsx
"use client";

import { useActionState } from "react";
import { createSignup, type SignupState } from "./actions";

const initialState: SignupState = { ok: false };

export function NativeSignupForm() {
  const [state, formAction, pending] = useActionState(createSignup, initialState);

  return (
    <form action={formAction}>
      <label>Name <input name="name" required minLength={2} /></label>
      <label>Email <input name="email" type="email" required /></label>
      <label>Age <input name="age" type="number" min={13} required /></label>
      <label><input name="terms" type="checkbox" required /> Accept terms</label>
      <button disabled={pending}>{pending ? "Saving…" : "Save"}</button>
      {state.message && <p role="status">{state.message}</p>}
    </form>
  );
}

For pending UI in a descendant component, React’s useFormStatus reads the nearest form submission status. Do not combine this native action flow with an RHF handleSubmit handler unless you intentionally design and test the handoff; they otherwise represent competing submission owners.

Return errors that are useful and accessible

  • Associate every label with its control through matching htmlFor and id.
  • Render field messages near their fields with role="alert"; use role="status" for non-error success or pending text.
  • Keep server messages serializable: strings, arrays, and plain objects are safer than errors or class instances.
  • Do not expose database details or authorization internals in a validation response.
  • For a server response containing field errors, map them into RHF with setError if you need the same inline presentation after submission.

Troubleshooting common failures

The action receives unexpected values

Checkboxes are omitted when unchecked, numbers arrive as strings, and duplicate names can produce multiple values. Read known keys, normalize deliberately, and use z.coerce or preprocessing where appropriate.

The action reports a wrong argument count

An action passed to useActionState receives previous state first. Define (previousState, formData), not just (formData).

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Client validation passes but the mutation fails validation

This usually means the client and server imported different schemas, normalization differs, or a caller bypassed the UI. Import one shared schema and keep server parsing authoritative.

Errors disappear on refresh or navigation

Action state is request state. Persist only successful domain data; do not treat a client state variable as durable storage.

Async refinements throw or never finish

Use asynchronous parsing for schemas with asynchronous refinements or transforms and await it inside the action. Do not call a synchronous parser for an async schema.

RHF types do not match transformed values

Declare useForm<z.input<typeof schema>, unknown, z.output<typeof schema>>. Input describes controls; output describes parsed mutation data.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Performance, reliability, and cost considerations

Client parsing avoids a round trip for obvious mistakes, but it also adds JavaScript and duplicate execution. For simple forms, native constraints plus a Server Action are often easier to maintain. RHF is justified when you need dynamic fields, touched-state rules, custom validation timing, or rich client feedback.

Server validation should remain deterministic and cheap before authorization-dependent or transactional work. Perform the parse before opening an expensive transaction, then apply authorization and mutation logic in a controlled order appropriate to your application. Treat retries and duplicate submissions as a domain concern: use idempotency keys or database uniqueness constraints where creating the same record twice would be harmful.

Or skip the browser setup

If you are generating screenshots of your form states for documentation, QA, or an AI workflow, ScreenshotNeo provides a single-call website screenshot API. It accepts consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing status. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/signup -o shot.webp

See the ScreenshotNeo API documentation for all options, including full-page capture, CSS-selector elements, device presets, custom CSS and JavaScript, waits, request blocking, headers and cookies, PDF output, signed links, asynchronous jobs, bulk capture, caching, and the usage API. One thousand screenshots per month are free with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

FAQ

Are developers still using React Hook Form with Next.js 15?

Yes. RHF remains useful for client-managed interaction, but its resolver does not replace Server Action validation. Choose it for the client experience you need, not as a trust mechanism.

Can one schema safely serve both environments?

Yes, provided the schema module has no server-only imports. Keep database checks and authorization in the action rather than inside a client-importable schema.

Should every field error be returned to the browser?

Return the minimum information needed to correct input. Avoid revealing whether sensitive records or accounts exist.

Frequently Asked Questions

Does a Zod resolver validate data submitted outside the browser?

No. It runs in the client bundle. Parse the received FormData again inside the Server Action before any mutation.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

When is the native action approach preferable?

Use it when HTML constraints and server-returned state are enough and you want the simplest submission path or documented progressive-enhancement behavior.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.