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.
| 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.
#1 Best Overall
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.
// 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.
Rank #2
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.
Do these 3 things before closing this tab:
1Repair Windows errors before they cause bigger problems2Scan for outdated or missing drivers - takes under a minute3Clear out junk files and repair common Windows errorsNative 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.
Rank #3
// 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
htmlForandid. - Render field messages near their fields with
role="alert"; userole="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
setErrorif 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).
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.
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.
The Tool Desk
Outbyte Driver Updater FREEScan for outdated or missing drivers - takes under a minuteDriver Scan →Outbyte PC Repair FREEClear out junk files and repair common Windows errorsFree Scan →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.
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.
Quick Recap
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.

