Close Menu
ToolTechBlogToolTechBlog

    Subscribe to Updates

    Get the latest creative news from FooBar about art, design and business.

    What's Hot

    Here’s what you need to know before buying a wireless CarPlay adapter

    October 25, 2026

    Your iPhone can spot scams for you with iOS 27 – but you have to opt in

    October 25, 2026

    7 Checks Developers Should Make So AI Search Can Read Their Site

    October 25, 2026
    Facebook X (Twitter) Instagram
    ToolTechBlogToolTechBlog
    • Home
    • AI Tools
    • Web Hosting
    • Tech
    • Digital Marketing
    • Business Software
    • VPN & Cybersecurity
    ToolTechBlogToolTechBlog
    Home»Web Hosting»React Form Validation with React Hook Form and Zod
    Web Hosting

    React Form Validation with React Hook Form and Zod

    Tool Tech TeamBy Tool Tech TeamOctober 16, 2026No Comments12 Mins Read
    Facebook Twitter Pinterest Telegram LinkedIn Tumblr WhatsApp Email
    React Form Validation with React Hook Form and Zod
    Share
    Facebook Twitter LinkedIn Pinterest Telegram Email

    React Form Validation Using Shared Zod Schemas and Server Actions

    SitePoint Team

    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-demo

    Each 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 vitest

    Add @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

    1. Single schema, two runtimes. Define validation once in Zod, execute it on both client and server.
    2. Use zodResolver for instant client feedback. It connects React Hook Form to the schema without manual validation functions.
    3. Re-validate with safeParse in every Server Action. Client-side validation can always be bypassed.
    4. Map server errors back to fields with setError. This keeps the feedback loop closed between server and UI.
    5. Apply aria-invalid and aria-describedby on every input. Screen readers announce errors in context following the ARIA authoring practices.
    6. Set mode to 'onTouched' to avoid premature errors. 'onBlur' validates on blur only and skips re-validation on subsequent changes; 'onTouched' does both and is generally preferable.
    7. Unit-test the schema, not just the UI. Schema tests run in milliseconds and catch rule regressions early.
    8. Type-infer form data from the schema with z.infer. Never manually duplicate types that the schema already defines.
    9. If your form handles file uploads, validate MIME type and size on the client with a custom Zod refinement, and inspect raw FormData entries on the server since uploaded files arrive in the server’s FormData rather than in a typed object.
    10. Disable the submit button during isSubmitting to prevent duplicate submissions during network latency. Handle root-level server errors via setError('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 zodResolver and 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.

    Form Hook React Validation
    Share. Facebook Twitter Pinterest LinkedIn Tumblr Email
    Tool Tech Team
    • Website

    Related Posts

    7 Checks Developers Should Make So AI Search Can Read Their Site

    October 25, 2026

    Best AI Recruiting Tools for Agencies and Staffing Firms in 2026

    October 24, 2026

    A Distributed Systems Problem

    October 24, 2026

    Best Claude Tracking Tools in 2026: 6 Options Compared

    October 23, 2026

    Best LinkedIn Recruiter Alternatives in 2026

    October 23, 2026

    What “Clear Form” Should Really Do: Managing Draft Data in JavaScript

    October 22, 2026
    Leave A Reply Cancel Reply

    Top posts
    Tech

    Here’s what you need to know before buying a wireless CarPlay adapter

    By Tool Tech Team
    Business Software

    Your iPhone can spot scams for you with iOS 27 – but you have to opt in

    By Tool Tech Team
    Web Hosting

    7 Checks Developers Should Make So AI Search Can Read Their Site

    By Tool Tech Team
    Editors Picks

    Here’s what you need to know before buying a wireless CarPlay adapter

    October 25, 2026

    Your iPhone can spot scams for you with iOS 27 – but you have to opt in

    October 25, 2026

    7 Checks Developers Should Make So AI Search Can Read Their Site

    October 25, 2026

    Starlink seems to be working normally again in Ukraine

    October 25, 2026
    About Us

    Welcome to ToolTechBlog, your trusted source for the latest insights, reviews, and practical guides on AI tools, business software, cybersecurity, web hosting, and consumer technology.
    Our mission is simple: to help individuals, entrepreneurs, freelancers, students, and businesses discover the right digital tools to improve productivity, streamline workflows, and make informed technology decisions.

    Our Picks

    Here’s what you need to know before buying a wireless CarPlay adapter

    October 25, 2026

    Your iPhone can spot scams for you with iOS 27 – but you have to opt in

    October 25, 2026

    7 Checks Developers Should Make So AI Search Can Read Their Site

    October 25, 2026
    Top Reviews

    The AI Hype Index: Unsexy AI

    July 29, 2026

    What it is and How to Fix it

    July 29, 2026

    LG to Ban Residential Proxies from Smart TV Apps

    July 29, 2026

    © 2026 tooltechblog.com. All rights reserved. Designed by DD.

    • About Us
    • Contact Us
    • Terms and Conditions
    • Privacy Policy
    • Disclaimer

    Type above and press Enter to search. Press Esc to cancel.