Next.js Forms with React Hook Form and Zod: Type-Safe Validation with Server Actions (2026)
Build type-safe forms in Next.js with React Hook Form, Zod, and Server Actions. Includes useActionState, file uploads, error handling, and progressive enhancement patterns for Next.js 15 and 16.
The cleanest way to build Next.js forms with React Hook Form and Zod in 2026 is to combine React Hook Form for client-side state, Zod for shared schema validation, and Server Actions with React 19's useActionState hook for mutations. You get type-safe end-to-end validation, progressive enhancement that works without JavaScript, low re-render counts, and a single source of truth for form rules that runs on both the client and the server. The pattern below works on Next.js 15 and 16 with the App Router, and it's the exact setup I've shipped in three production apps this year.
Define your form shape once with a Zod schema and reuse it on the client (via zodResolver) and inside the Server Action. The schema is the contract.
React Hook Form 7.54+ uses uncontrolled inputs by default, so most forms re-render only when validation state changes.
React 19's useActionState replaces the deprecated useFormState and returns [state, formAction, isPending]. Wire it to a Server Action that returns typed errors.
Always validate on the server. Client validation is a UX optimization, not a security boundary.
useFormStatus reads the parent form's pending state from a child Submit button without prop drilling.
For files, pass FormData directly to a Server Action. RHF integrates via register with native file inputs.
Install React Hook Form and Zod
So, let's start from a Next.js 15 or 16 App Router project and install the three runtime dependencies you'll actually need. @hookform/resolvers bridges Zod into React Hook Form's resolver API, so you don't have to write a custom validation function (I've done that the hard way once, and never again).
As of May 2026 the relevant versions are react-hook-form 7.54.x, zod 3.24.x, and @hookform/resolvers 3.10.x. Zod 4 is in release-candidate at the time of writing, but the API for z.object, z.string, safeParse, and z.infer shown below is stable across both major versions. React 19 ships with Next.js 15 and 16, so the useActionState and useFormStatus hooks are available without polyfills.
You don't need any extra packages for the action handler itself, since Next.js Server Actions are built into the framework. If you also want a UI library on top, shadcn/ui's Form component wraps React Hook Form and works with everything below without modification.
Define a shared Zod schema
Honestly, the single most important step is defining the form's shape once and importing it everywhere. Put the schema in a file that has no 'use client' or 'use server' directive so both environments can import it. A typical signup form might look like this:
// lib/schemas/signup.ts
import { z } from 'zod';
export const SignupSchema = z
.object({
email: z.string().email('Enter a valid email address.'),
password: z
.string()
.min(8, 'Password must be at least 8 characters.')
.regex(/[A-Z]/, 'Include at least one uppercase letter.')
.regex(/[0-9]/, 'Include at least one number.'),
confirmPassword: z.string(),
acceptTerms: z.literal(true, {
errorMap: () => ({ message: 'You must accept the terms.' }),
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match.',
path: ['confirmPassword'],
});
export type SignupInput = z.infer<typeof SignupSchema>;
z.infer derives the TypeScript type from the schema, so your form values, your Server Action parameter, and your database insert can all share one definition. If you later add a username field, you change one file and TypeScript will surface every call site that needs updating. That's the win I keep coming back for.
Write the Server Action
The Server Action is the server boundary. Accept the previous state and the FormData, parse it with safeParse so failures don't throw, and return a typed result the client can render. Returning structured errors (instead of throwing) keeps the form recoverable.
Notice the two layers of failure handling. Validation errors return field-level messages, while infrastructure errors return a single formError. The redirect call uses Next.js's special error-throwing redirect, so it interrupts the function and the client never sees a "success" state. The user just moves to the new route. For more on building solid mutations, see our complete guide to Next.js Server Actions.
Build the client form with useActionState
The simplest possible client component uses useActionState alone, with no React Hook Form involved. This pattern is the official React 19 recommendation for any form that just needs to call a Server Action and render the response.
The action={formAction} binding is the key piece. React intercepts the submit event, builds the FormData from the form's inputs, calls the Server Action, and threads its return value back into state. The third tuple value, isPending, is true while the action is in flight, so use it to disable the submit button and avoid double submissions (a bug I shipped to production once, before I learned). According to the React documentation for useActionState, this hook replaces the older useFormState from React 18, which is now deprecated.
Add React Hook Form for richer UX
Pure useActionState works, but it only reports errors after a full submit round trip. For a better experience, which means instant validation as the user types, field-level focus management, and conditional fields, you layer React Hook Form on top. The trick is that React Hook Form 7.54 understands Server Actions: pass the Server Action to handleSubmit's wrapper or call it inside onSubmit.
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useActionState, useTransition } from 'react';
import { SignupSchema, type SignupInput } from '@/lib/schemas/signup';
import { signupAction } from './actions';
export function SignupForm() {
const [state, formAction, isPending] = useActionState(signupAction, { ok: false });
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SignupInput>({
resolver: zodResolver(SignupSchema),
mode: 'onBlur',
});
return (
<form
action={formAction}
onSubmit={(e) => {
// Run client validation first; if it passes, let the form action proceed.
void handleSubmit(() => {})(e);
}}
noValidate
>
<input {...register('email')} type="email" placeholder="Email" />
{(errors.email?.message || state.fieldErrors?.email?.[0]) && (
<p role="alert">{errors.email?.message ?? state.fieldErrors?.email?.[0]}</p>
)}
<input {...register('password')} type="password" placeholder="Password" />
<input {...register('confirmPassword')} type="password" placeholder="Confirm" />
<label>
<input {...register('acceptTerms')} type="checkbox" /> I accept the terms
</label>
<button disabled={isPending}>{isPending ? '…' : 'Sign up'}</button>
</form>
);
}
The key idea: React Hook Form runs the Zod schema in the browser via zodResolver and prevents the submit if it fails, so a malformed payload never hits the network. When the payload is valid client-side, the native form action={formAction} takes over and posts to the server. The server then runs the exact same schema again. Defense in depth. Merging client and server error messages in the same <p role="alert"> keeps the JSX simple and screen-reader friendly.
Use useFormStatus for the submit button
If your submit button lives in a child component (a design-system Button, for example), you don't need to drill isPending down. The useFormStatus hook reads the status of the nearest parent <form>:
useFormStatus must be called from a component that's a descendant of the form, not the form itself. That's the most common mistake, and I've made it more than once. It also returns data (the FormData being submitted) and method, which are useful for optimistic UI. Pair this hook with useOptimistic to render the new row in a list before the server confirms it, which keeps perceived latency near zero. Our guide on Next.js streaming and Suspense covers complementary patterns for skeleton states.
Should you validate on the client or server?
The honest answer: both, but the server is the only one that matters for security. Client validation exists to give fast feedback and reduce server load, but a determined user can disable JavaScript or send a raw request with curl. If your only validation lives in the browser, your database can end up filled with whatever an attacker decides to send. Here's how the two layers compare:
Dimension
Client validation (RHF + zodResolver)
Server validation (Server Action + safeParse)
Purpose
UX, instant feedback as user types
Security, guarantees data integrity
Runs when
onChange / onBlur / before submit
On every form submission
Bypassable
Yes (disable JS, devtools, curl)
No
Latency
0 ms
50–300 ms round trip
Required
Optional
Always
Source of rules
Same Zod schema
Same Zod schema
Because both layers import the same schema, you get the UX win of client validation without duplicating logic. If you decide to ship without client validation (smaller bundle, simpler code), the server still rejects bad input. The form just feels slower because errors only appear after a round trip.
Handle file uploads and FormData
React Hook Form treats <input type="file"> like any other registered input. The trick is that Zod can't validate browser File objects with z.string(). You need z.instanceof(File) on the client, and on the server you read FormData entries that are already Blob/File instances.
// lib/schemas/avatar.ts
import { z } from 'zod';
export const AvatarSchema = z.object({
avatar: z
.instanceof(File)
.refine((f) => f.size < 2 * 1024 * 1024, 'Max 2 MB.')
.refine((f) => ['image/png', 'image/jpeg'].includes(f.type), 'PNG or JPEG only.'),
alt: z.string().min(1).max(120),
});
On the server, retrieve the file with formData.get('avatar') (it will be a File object) and stream it to your storage provider. I hit this exact gotcha shipping an avatar uploader last quarter: forgetting that FormData.get returns FormDataEntryValue, not File, until you narrow it. For the upload side of the pipeline, including S3 presigned URLs and chunked uploads, see our deep dive on Next.js file uploads with Server Actions and S3.
How do you show validation errors in Next.js?
There are three common places errors surface, and each has its own pattern:
Field-level errors: read from formState.errors[fieldName] for RHF, or from state.fieldErrors[fieldName] for the Server Action result. Render them in a <p role="alert"> next to the input so screen readers announce them.
Form-level errors: a single message at the top of the form (e.g., "Email already in use"). Return this from the Server Action as a top-level field, separate from fieldErrors.
Unexpected errors: bugs, network failures, third-party API outages. Let these bubble up to the nearest error.tsx boundary. Don't try to catch them in the form. The pattern is covered in our guide to Next.js App Router error handling.
For accessibility, always pair an error message with the input via aria-describedby and set aria-invalid="true" on the input when it has an error. React Hook Form's register doesn't do this for you automatically.
Progressive enhancement without JavaScript
One of the strongest arguments for Server Actions is that they work without JavaScript. If you write your form as <form action={formAction}> with normal HTML inputs, the browser will fall back to a standard POST submission when the client bundle hasn't loaded yet, for example on a slow 3G connection or during the page's first paint. The Server Action runs on the server, validates with Zod, and either redirects (success) or re-renders the page with errors (failure).
To keep this fallback working, two rules:
Use name attributes on every input, not just register from RHF. register sets the name automatically, but if you build a custom input, double-check.
Don't rely on e.preventDefault() in onSubmit. That breaks the no-JS path. RHF's handleSubmit is safe because it only short-circuits when validation fails.
This pattern is officially endorsed in the Next.js useActionState reference, which calls out progressive enhancement as a first-class concern of the App Router's form model.
Frequently Asked Questions
Do I need React Hook Form if I already use Server Actions?
No. useActionState alone handles submission, pending state, and errors for simple forms. Add React Hook Form when you need onChange/onBlur validation, field arrays, conditional fields, or want to keep re-renders to a minimum on large forms.
What is the difference between useFormState and useActionState?
useFormState shipped in React 18 and is now deprecated. useActionState (React 19, available in Next.js 15 and 16) is the replacement. It adds an isPending boolean to the returned tuple, so you no longer need a separate useFormStatus in the parent component just to know whether the action is in flight.
Can I use Zod 4 with @hookform/resolvers?
Yes. @hookform/resolvers 3.10+ supports both Zod 3 and Zod 4 release candidates. The zodResolver(schema) call signature is unchanged. If you upgrade, watch out for breaking changes in z.string().email() (which becomes z.email() in Zod 4) and a few error map renames.
Why does my form lose state on submit?
Server Actions trigger a server re-render, which can reset uncontrolled inputs. Fix it by returning the submitted values from the Server Action and seeding them back into the form with defaultValue from state, or by switching to controlled inputs managed by React Hook Form so state lives in the client.
How do I show a toast on successful form submission?
Return { ok: true } from your Server Action and watch state.ok in a useEffect in the client component. When it flips to true, call your toast library. For redirects, use redirect() inside the action: it will navigate before the client ever sees a success state, so use the effect pattern only when you want the user to stay on the page.
Build instant-feeling forms and lists in Next.js with useOptimistic, useActionState, and useFormStatus. Production patterns, type-safe rollback, and real Server Action examples.
How to use next/image in Next.js 16 to ship AVIF/WebP, configure remotePatterns safely, add blur placeholders for remote images, and lock down LCP for Core Web Vitals.