React Form Validation Using Shared Zod Schemas and Server Actions

SitePoint TeamPublished inJavaScript·Programming·Web·
October 16, 2026
The AI briefing for Developers
Stay up to date with AI tools, model releases, and developer workflows that matter.
Weekly. Free. One click to leave.
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
- Premium Results
- Publish articles on SitePoint
- Daily curated jobs
- Learning Paths
- Discounts to dev tools
7 Day Free Trial. Cancel Anytime.
Duplicated validation logic is one of the most persistent sources of bugs in React applications. A client-side rule changes, the server-side copy stays the same, and invalid data slips through the gap. React form validation that relies on a single Zod schema, shared between React Hook Form on the client and a Next.js Server Action on the server, eliminates that drift entirely. This tutorial walks through building that pattern from scratch: a contact form with client-side validation via zodResolver, server-side re-validation with safeParse, accessible error rendering following ARIA authoring practices, and a Vitest test suite that locks down the schema’s correctness.
The result is a Next.js App Router contact form where every validation rule lives in one file. When the server rejects a submission, field-level errors map back to individual inputs using setError, and screen readers announce them through aria-describedby associations.
Prerequisites: you should know React, TypeScript, and the Next.js App Router. You will also need Node 20.9 or later, which is the minimum for current Next.js releases. The code below was type-checked with TypeScript 5.9.3 and 7.0.2 and the tests were run with Vitest 5.0.5, React Hook Form 7.90.0, @hookform/resolvers 5.9.1 and Zod 4.6.7.
Table of Contents
Project Setup and Dependencies
Scaffold the Next.js App
Start with the standard create-next-app scaffolding. The flags below suppress all interactive prompts and ensure the resulting directory structure matches the file paths used throughout this tutorial:
npx create-next-app@latest contact-form-demo --typescript--app --no-eslint --no-tailwind --no-src-dir --import-alias '@/*'cd contact-form-demoEach flag matters: --no-src-dir places the app/ directory at the project root (rather than inside src/), and --import-alias '@/*' configures the @/ path alias in tsconfig.json so that imports like @/lib/schemas/contact resolve correctly.
Install Validation and Testing Packages
The form layer requires three packages: react-hook-form for state management, @hookform/resolvers for connecting external schema libraries, and zod for schema definition. Testing relies on vitest.
npminstall react-hook-form @hookform/resolvers zodnpminstall-D vitestAdd @testing-library/react if you plan to write component-level tests beyond the schema tests shown in this tutorial.
After installation, the relevant portion of package.json should look similar to:
{"dependencies":{"next":"^16.4.2","react":"^19.3.1","react-dom":"^19.3.1","react-hook-form":"^7.90.0","@hookform/resolvers":"^5.9.1","zod":"^4.6.7"},"devDependencies":{"vitest":"^5.0.5","typescript":"^5.9.3"}}The devDependencies block shown is abridged. Your actual file will also contain @types/react, @types/react-dom, @types/node, and ESLint-related packages installed by create-next-app. Those are omitted here for brevity.
Create the Schema File
The schema lives in its own file so both the client component and the Server Action can import it without pulling in unnecessary dependencies.
import{ z }from"zod";exportconst contactSchema = z.object({name: z.string().min(2,{ message:"Name must be at least 2 characters."}),email: z.email({ message:"Please enter a valid email address."}),message: z.string().min(10,{ message:"Message must be at least 10 characters."}).max(500,{ message:"Message must not exceed 500 characters."}),});exporttypeContactFormData= z.infer<typeof contactSchema>;Each field carries a custom error message. This tutorial uses Zod 4, where z.email() replaces the deprecated z.string().email(); on Zod 3 keep the older form. The ContactFormData type is inferred directly from the schema with z.infer<typeof contactSchema>, so the TypeScript type and the runtime validation rules can never diverge.
Why This File Is the Single
Both the client component and the Server Action import contactSchema from this one module. On the client, zodResolver wraps the schema so React Hook Form can evaluate it during its validation cycle. On the server, safeParse() runs the same rules against raw input and returns structured errors without throwing. You author the schema once, but it executes in two runtimes. Any change to a validation rule propagates to both sides immediately.
You author the schema once, but it executes in two runtimes. Any change to a validation rule propagates to both sides immediately.
Building the Form Component with React Hook Form
The complete component is shown below as a single file. It combines the hook setup, the onSubmit handler, and the JSX return in the order they appear in the source.
"use client";import React from"react";import{ useForm }from"react-hook-form";importtype{ Path }from"react-hook-form";import{ zodResolver }from"@hookform/resolvers/zod";import{ contactSchema,typeContactFormData}from"@/lib/schemas/contact";import{ submitContactForm }from"@/app/actions/contact";import{ applyServerErrors }from"@/lib/utils/applyServerErrors";constVALID_FIELDS=["name","email","message"]asconst satisfies ReadonlyArray<Path<ContactFormData>>;exportdefaultfunctionContactForm(){const{register,handleSubmit,setError,reset,formState:{ errors, isSubmitting },}=useForm<ContactFormData>({resolver:zodResolver(contactSchema),mode:"onTouched",});const[successMessage, setSuccessMessage]= React.useState<string|null>(null);asyncfunctiononSubmit(data: ContactFormData){setSuccessMessage(null);try{const result =awaitsubmitContactForm(data);if(!result.success && result.fieldErrors){applyServerErrors(setError, result.fieldErrors,VALID_FIELDS);return;}if(result.success){reset();setSuccessMessage("Message sent successfully!");}}catch(err){console.error("ContactForm: unexpected submission error", err);setError("root",{type:"server",message:"A network error occurred. Please try again.",});}}return(<form onSubmit={handleSubmit(onSubmit)} noValidate aria-busy={isSubmitting}>{successMessage &&(<div role="status">{successMessage}</div>)}{errors.root &&(<div role="alert">{errors.root.message}</div>)}<div><label htmlFor="name">Name</label><inputid="name"type="text"{...register("name")}aria-invalid={!!errors.name}aria-describedby="name-error"/><span id="name-error" role="alert">{errors.name?.message ??""}</span></div><div><label htmlFor="email">Email</label><inputid="email"type="email"{...register("email")}aria-invalid={!!errors.email}aria-describedby="email-error"/><span id="email-error" role="alert">{errors.email?.message ??""}</span></div><div><label htmlFor="message">Message</label><textareaid="message"rows={5}{...register("message")}aria-invalid={!!errors.message}aria-describedby="message-error"/><span id="message-error" role="alert">{errors.message?.message ??""}</span></div><button type="submit" disabled={isSubmitting}>{isSubmitting ?"Sending…":"Send Message"}</button></form>);}Wiring useForm to zodResolver
The useForm hook accepts a resolver option that delegates validation to Zod. Setting mode to 'onTouched' means fields validate on blur and re-validate on subsequent changes, giving users immediate feedback without triggering errors before they interact with a field.
The register function connects inputs to React Hook Form’s internal state. errors provides per-field error objects, and isSubmitting tracks whether an async submission is in flight.
Rendering Inputs with Accessible Error Messages
Every input receives aria-invalid when its corresponding error exists and aria-describedby pointing to a <span> that holds the error message. The aria-describedby attribute is always present (pointing to a consistently rendered <span>) so that screen readers maintain the association even when errors are cleared. This follows the ARIA authoring practices for inline error messages: aria-describedby links the error <span> to its input, and aria-invalid signals the error state, so screen readers announce the error when the user focuses the field.
The noValidate attribute on the <form> element turns off built-in browser validation so Zod’s rules run instead. The submit button disables itself during submission to prevent duplicate requests. The aria-busy attribute on the form communicates the loading state to assistive technology.
Handling Submission and Calling the Server Action
When React Hook Form’s client-side validation passes, onSubmit fires. It calls the Server Action, inspects the response for field errors, and maps any server-returned errors back to individual inputs using the applyServerErrors helper (defined in the “Mapping Server Errors” section below). A try/catch block wraps the handler so that network failures or unexpected Server Action crashes surface as a root-level form error instead of leaving the form stuck in a submitting state.
The type: 'server' string used inside applyServerErrors is a convention, not a React Hook Form requirement. It makes it possible to distinguish server-originated errors from client-originated ones when logging, styling, or routing analytics events.
Creating the Server Action with Re-Validation
Define the Action
The Server Action imports the same contactSchema and runs safeParse against the incoming data. If parsing fails, the flattened field errors return to the client. If it succeeds, business logic executes inside a try/catch block to ensure unexpected errors come back as structured responses rather than crashing the action. In a real application, the success branch would perform a concrete operation like inserting a row into a contacts table
"use server";import{ z }from"zod";import{ contactSchema }from"@/lib/schemas/contact";typeActionResult=|{ success:true; fieldErrors?:never}|{ success:false; fieldErrors: Record<string,string[]>};exportasyncfunctionsubmitContactForm(rawData:unknown):Promise<ActionResult>{const result = contactSchema.safeParse(rawData);if(!result.success){const raw = z.flattenError(result.error).fieldErrors;const fieldErrors: Record<string,string[]>= Object.fromEntries(Object.entries(raw).filter((entry): entry is[string,string[]]=>Array.isArray(entry[1])&& entry[1].length >0));return{ success:false, fieldErrors };}try{return{ success:true};}catch(err){console.error("submitContactForm: business logic error", err);return{success:false,fieldErrors:{root:["An unexpected error occurred. Please try again later."],},};}}The parameter is typed unknown deliberately. Typing it ContactFormData would let TypeScript accept the data without re-parsing, defeating server-side re-validation.
The z.flattenError() helper turns a ZodError into a flat { formErrors: string[], fieldErrors: { name: string[], email: string[] } } shape, which maps directly to the setError loop on the client. (Zod 3 and 4 also have an error.flatten() method, deprecated in Zod 4.) Zod types each field value as optionaldefined entries and the returned fieldErrors object is a safe Record<string, string[]>
Why Server Re-Validation Matters
Client-side validation runs in the browser. A user can bypass it entirely by disabling JavaScript, manipulating form data through DevTools, or submitting requests directlye schema guarantees that identical rules apply regardless of how the request arrives
Server re-validation using the same schema guarantees that identical rules apply regardless of how the request arrives.
Implement rate limiting and CSRF token verification alongside schema validation. These complementary measures address different threat vectors, though they fall outside the scope of this tutorial.
Mapping Server Errors Back to Inputs with setError
The applyServerErrors helper (used in the onSubmit handler above) takes each key from the server’s fieldErrors object and injects it into React Hook Form’s error state. Because the error spans in the JSX read from formState.errors.fieldName, the correct <span> re-renders automatically and aria-invalid toggles to true on the corresponding input. The accessible error contract stays intact without adding manual DOM queries or event listeners.
For unexpected errors that do not map to a specific field, React Hook Form supports root-level errors (requires react-hook-form >=7.34.0; alternatively use setError(‘root’, …) on earlier versions). This is useful for handling cases like network failures or internal server errors where no individual field is at fault
To keep this pattern DRY across multiple forms, the mapping logic lives in a reusable helper. The helper accepts an explicit list of valid field paths and routes any unrecognized keys to a root-level error, preventing server-supplied keys from silently corrupting form state:
import{ UseFormSetError, FieldValues, Path }from"react-hook-form";constSERVER_ERROR_TYPE="server"asconst;exportfunctionapplyServerErrors<Textends FieldValues>(setError: UseFormSetError<T>,fieldErrors: Record<string,string[]>,validFields: ReadonlyArray<Path<T>>):void{const validSet =newSet<string>(validFields);for(const[field, messages]of Object.entries(fieldErrors)){if(!messages || messages.length ===0)continue;if(validSet.has(field)){setError(field as Path<T>,{type:SERVER_ERROR_TYPE,message: messages[0],});}else{setError("root"as Path<T>,{type:SERVER_ERROR_TYPE,message: messages[0],});}}}This helper is generic over the form’s field values type, so it works with any Zod schema and any form that uses React Hook Form. The validFields parameter ensures that only known form field paths are passed to setError, while any unexpected keys from the server response are safely routed to a root-level error. The ContactForm component imports it directly, replacing the inline loop shown in earlier drafts.
Testing the Schema with Vitest
Vitest Configuration
Create vitest.config.ts at the project root. This configures the @/ path alias so Vitest can resolve the same imports used in the application code:
import{ defineConfig }from"vitest/config";import path from"path";exportdefaultdefineConfig({resolve:{alias:{"@": path.resolve(__dirname,"./"),},},test:{environment:"node",},});Unit Tests for Valid and Invalid Data
Because the schema is a standalone module with no UI dependencies, it can be tested in isolation with fast unit tests. Each test calls safeParse and asserts on the structured result.
import{ z }from"zod";import{ describe, it, expect }from"vitest";import{ contactSchema }from"@/lib/schemas/contact";describe("contactSchema",()=>{it("accepts a valid payload",()=>{const result = contactSchema.safeParse({name:"Jane Doe",email:"jane@example.com",message:"Hello, this is a valid message.",});expect(result.success).toBe(true);});it("rejects a name shorter than 2 characters",()=>{const result = contactSchema.safeParse({name:"J",email:"jane@example.com",message:"Hello, this is a valid message.",});expect(result.success).toBe(false);if(!result.success){expect(z.flattenError(result.error).fieldErrors.name).toContain("Name must be at least 2 characters.");}});it("rejects an invalid email address",()=>{const result = contactSchema.safeParse({name:"Jane Doe",email:"not-an-email",message:"Hello, this is a valid message.",});expect(result.success).toBe(false);if(!result.success){expect(z.flattenError(result.error).fieldErrors.email).toContain("Please enter a valid email address.");}});it("rejects a message shorter than 10 characters",()=>{const result = contactSchema.safeParse({name:"Jane Doe",email:"jane@example.com",message:"Short",});expect(result.success).toBe(false);if(!result.success){expect(z.flattenError(result.error).fieldErrors.message).toContain("Message must be at least 10 characters.");}});it("rejects a message exceeding 500 characters",()=>{const result = contactSchema.safeParse({name:"Jane Doe",email:"jane@example.com",message:"A".repeat(501),});expect(result.success).toBe(false);if(!result.success){expect(z.flattenError(result.error).fieldErrors.message).toContain("Message must not exceed 500 characters.");}});it("rejects a completely empty payload",()=>{const result = contactSchema.safeParse({});expect(result.success).toBe(false);});});These tests assert exact error messages, which catches regressions in user-facing copy as well as validation logic.
Running the Suite
Run the full suite with npx vitest run. For continuous feedback during development, npx vitest starts watch mode. Adding either command to a CI pipeline ensures that schema changes are verified on every push.
React Form Validation Best Practices Checklist
- Single schema, two runtimes. Define validation once in Zod, execute it on both client and server.
- Use
zodResolverfor instant client feedback. It connects React Hook Form to the schema without manual validation functions. - Re-validate with
safeParsein every Server Action. Client-side validation can always be bypassed. - Map server errors back to fields with
setError. This keeps the feedback loop closed between server and UI. - Apply
aria-invalidandaria-describedbyon every input. Screen readers announce errors in context following the ARIA authoring practices. - Set
modeto'onTouched'to avoid premature errors.'onBlur'validates on blur only and skips re-validation on subsequent changes;'onTouched'does both and is generally preferable. - Unit-test the schema, not just the UI. Schema tests run in milliseconds and catch rule regressions early.
- Type-infer form data from the schema with
z.infer. Never manually duplicate types that the schema already defines. - If your form handles file uploads, validate MIME type and size on the client with a custom Zod refinement, and inspect raw
FormDataentries on the server since uploaded files arrive in the server’sFormDatarather than in a typed object. - Disable the submit button during
isSubmittingto prevent duplicate submissions during network latency. Handle root-level server errors viasetError('root.serverError', ...)for failures that do not belong to a specific field.
Where to Go from Here
A single Zod schema, shared between React Hook Form’s zodResolver and a Next.js Server Action, eliminates validation drift. The setError mapping closes the feedback loop between server and client. ARIA attributes ensure that error states are perceivable by assistive technology. Vitest locks down the schema’s correctness independent of the UI.
A single Zod schema, shared between React Hook Form’s
zodResolverand a Next.js Server Action, eliminates validation drift.
From here, add file upload validation on the client with z.instanceof(File). Note that server-side file validation requires inspecting FormData entries directly, since uploaded files arrive in the server’s FormData rather than in a typed object. You can also integrate useFormStatus from React DOM for streaming submission indicators; this hook must live in a child component of the <form> element, not in the component that renders the form itself. For multi-step forms, split a larger schema across wizard steps using z.pick() or z.omit() so each step validates only its own subset.
For further reading on React Hook Form and Zod, see the relevant guides on SitePoint.
Sharing our passion for building incredible internet things.


