writing/tutorial/2026/07
TutorialJul 15, 2026·30 min read

Type-Safe Forms with React Hook Form v8, Zod v4, and Next.js 15 Server Actions

Build production-ready forms in Next.js 15 using React Hook Form v8 and Zod v4. This tutorial covers type-safe schema validation, Server Actions integration, optimistic UI with useActionState, file upload handling, and multi-step forms — all without a single API route.

Forms are the most common source of bugs in production web applications. An invalid email slips through, a required field passes as empty, or a server error leaves the user staring at a blank page with no feedback. React Hook Form v8 combined with Zod v4 and Next.js 15 Server Actions closes all three gaps — you get schema-driven validation that runs on both the client and the server, progressive enhancement so forms work even before JavaScript hydrates, and type safety that flows from the schema all the way to the database call.

This tutorial walks you through building a complete, production-grade contact form — the kind that a Tunisia-based software company or a MENA SaaS startup would actually ship. By the end you will have:

  • A Zod v4 schema shared between client and server
  • React Hook Form v8 managing field state with zero controlled components
  • A Next.js 15 Server Action validating and persisting the submission
  • Optimistic UI feedback using the useActionState hook
  • File attachment support with size and MIME-type validation
  • Accessible error messages linked to their fields

Why This Stack in 2026?

Three things happened in quick succession that make this combination the default choice for production forms:

Zod v4 (released early 2026) rewrote its internals for a 3–14x parse speed improvement and added first-class z.file() support for browser File objects. The API surface barely changed, so migration from v3 is a one-liner.

React Hook Form v8 dropped its peer dependency on React 18 and fully embraces React 19 patterns. The useFormState / useActionState integration is now official, meaning you can call a Server Action from RHF's handleSubmit and get back structured errors without any custom wiring.

Next.js 15 Server Actions are stable and support FormData, structured return values, and progressive enhancement out of the box. The compiler now inlines the action reference, so there is no endpoint to protect or rate-limit separately.

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ and pnpm installed
  • Basic familiarity with React and the Next.js App Router
  • TypeScript comfort (we use strict mode throughout)
  • A code editor (VS Code with the Zod extension recommended)

What You Will Build

A "Request a Proposal" form with the following fields:

FieldValidation
Full nameRequired, 2–100 characters
EmailValid email, lowercased
CompanyOptional, max 100 characters
Service interestEnum — one of four options
Budget rangeEnum — one of five ranges
MessageRequired, 20–2000 characters
AttachmentOptional PDF, max 5 MB

The form uses client-side validation for instant feedback and server-side re-validation inside the Server Action before the data reaches any storage layer.

Step 1: Create the Project

pnpm create next-app@latest proposal-form \
  --typescript \
  --tailwind \
  --app \
  --eslint \
  --src-dir \
  --import-alias "@/*"
cd proposal-form

Install the form and validation libraries:

pnpm add react-hook-form zod @hookform/resolvers

@hookform/resolvers is the official bridge between React Hook Form and schema validation libraries. It wraps Zod (and others) so RHF can call schema.parse() at the right moments.

Step 2: Define the Zod Schema

Create src/lib/schemas/proposal.ts. Keeping the schema in lib/ (not inside a component or a Server Action file) means both the client and server can import it without bundling issues.

// src/lib/schemas/proposal.ts
import { z } from "zod";
 
export const SERVICE_OPTIONS = [
  "web-development",
  "mobile-app",
  "ai-integration",
  "consulting",
] as const;
 
export const BUDGET_OPTIONS = [
  "under-5k",
  "5k-20k",
  "20k-50k",
  "50k-100k",
  "above-100k",
] as const;
 
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
const ACCEPTED_TYPES = ["application/pdf"] as const;
 
export const proposalSchema = z.object({
  fullName: z
    .string()
    .min(2, "Name must be at least 2 characters")
    .max(100, "Name must be under 100 characters")
    .trim(),
  email: z
    .string()
    .email("Please enter a valid email address")
    .toLowerCase(),
  company: z
    .string()
    .max(100, "Company name must be under 100 characters")
    .trim()
    .optional()
    .or(z.literal("")),
  service: z.enum(SERVICE_OPTIONS, {
    errorMap: () => ({ message: "Please select a service" }),
  }),
  budget: z.enum(BUDGET_OPTIONS, {
    errorMap: () => ({ message: "Please select a budget range" }),
  }),
  message: z
    .string()
    .min(20, "Message must be at least 20 characters")
    .max(2000, "Message must be under 2000 characters")
    .trim(),
  attachment: z
    .instanceof(File)
    .refine((f) => f.size <= MAX_FILE_SIZE, "File must be under 5 MB")
    .refine(
      (f) => ACCEPTED_TYPES.includes(f.type as (typeof ACCEPTED_TYPES)[number]),
      "Only PDF files are accepted"
    )
    .optional(),
});
 
export type ProposalFormData = z.infer<typeof proposalSchema>;

A few things worth noting here:

  • .toLowerCase() on the email field transforms the value at parse time, so Alice@Example.COM becomes alice@example.com before it ever reaches your database.
  • .trim() strips accidental leading/trailing whitespace from all text fields.
  • z.instanceof(File) uses Zod v4's native File awareness. On the server, where File is not a global, you would use z.custom() instead — we address this in Step 4.
  • The .optional().or(z.literal("")) pattern on company handles the case where an HTML input submits an empty string rather than undefined.

Step 3: Build the Server Action

Create src/app/actions/proposal.ts. Server Actions must be in files marked with "use server" at the top.

// src/app/actions/proposal.ts
"use server";
 
import { z } from "zod";
import { proposalSchema } from "@/lib/schemas/proposal";
 
// Server-side schema: File becomes a Blob check since Node has no global File
const serverSchema = proposalSchema.extend({
  attachment: z
    .instanceof(Blob)
    .refine((b) => b.size <= 5 * 1024 * 1024, "File must be under 5 MB")
    .refine(
      (b) => b.type === "application/pdf",
      "Only PDF files are accepted"
    )
    .optional(),
});
 
export type ProposalActionState = {
  success: boolean;
  message: string;
  errors?: Partial<Record<keyof z.infer<typeof proposalSchema>, string[]>>;
};
 
export async function submitProposal(
  _prev: ProposalActionState,
  formData: FormData
): Promise<ProposalActionState> {
  // Convert FormData to a plain object the schema can parse
  const raw = {
    fullName: formData.get("fullName"),
    email: formData.get("email"),
    company: formData.get("company"),
    service: formData.get("service"),
    budget: formData.get("budget"),
    message: formData.get("message"),
    attachment: formData.get("attachment") as Blob | null,
  };
 
  // Remove the attachment key entirely if no file was sent
  if (!raw.attachment || (raw.attachment as Blob).size === 0) {
    delete raw.attachment;
  }
 
  const parsed = serverSchema.safeParse(raw);
 
  if (!parsed.success) {
    const fieldErrors = parsed.error.flatten().fieldErrors;
    return {
      success: false,
      message: "Please fix the errors below and try again.",
      errors: fieldErrors,
    };
  }
 
  // At this point parsed.data is fully typed and validated
  const { fullName, email, company, service, budget, message } = parsed.data;
 
  // TODO: save to your database, send a notification email, etc.
  console.log("Proposal received:", {
    fullName,
    email,
    company,
    service,
    budget,
    message,
    hasAttachment: !!raw.attachment,
  });
 
  // Simulate network latency in development
  await new Promise((r) => setTimeout(r, 400));
 
  return {
    success: true,
    message: `Thank you ${fullName}! We will get back to you at ${email} within 24 hours.`,
  };
}

The function signature (prev, formData) is required by useActionState — the hook always passes the previous state as the first argument and the new FormData as the second.

Step 4: Create the Form Component

Create src/components/ProposalForm.tsx. Because this component uses browser hooks it must be a Client Component.

// src/components/ProposalForm.tsx
"use client";
 
import { useActionState, useEffect, useRef } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { proposalSchema, type ProposalFormData, SERVICE_OPTIONS, BUDGET_OPTIONS } from "@/lib/schemas/proposal";
import { submitProposal, type ProposalActionState } from "@/app/actions/proposal";
 
const INITIAL_STATE: ProposalActionState = {
  success: false,
  message: "",
};
 
const SERVICE_LABELS: Record<(typeof SERVICE_OPTIONS)[number], string> = {
  "web-development": "Web Development",
  "mobile-app": "Mobile App",
  "ai-integration": "AI Integration",
  "consulting": "Consulting",
};
 
const BUDGET_LABELS: Record<(typeof BUDGET_OPTIONS)[number], string> = {
  "under-5k": "Under $5,000",
  "5k-20k": "$5,000 – $20,000",
  "20k-50k": "$20,000 – $50,000",
  "50k-100k": "$50,000 – $100,000",
  "above-100k": "Above $100,000",
};
 
export function ProposalForm() {
  const [state, action, isPending] = useActionState(submitProposal, INITIAL_STATE);
  const formRef = useRef<HTMLFormElement>(null);
 
  const {
    register,
    handleSubmit,
    formState: { errors },
    reset,
    setError,
  } = useForm<ProposalFormData>({
    resolver: zodResolver(proposalSchema),
    defaultValues: {
      fullName: "",
      email: "",
      company: "",
      message: "",
    },
  });
 
  // Sync server-side errors back into RHF's error state
  useEffect(() => {
    if (state.errors) {
      for (const [field, messages] of Object.entries(state.errors)) {
        if (messages && messages.length > 0) {
          setError(field as keyof ProposalFormData, {
            type: "server",
            message: messages[0],
          });
        }
      }
    }
    if (state.success) {
      reset();
      formRef.current?.reset(); // also reset the native file input
    }
  }, [state, setError, reset]);
 
  // RHF's handleSubmit runs client validation; on success it submits the FormData
  const onSubmit = handleSubmit(() => {
    formRef.current?.requestSubmit();
  });
 
  return (
    <form ref={formRef} action={action} onSubmit={onSubmit} noValidate>
      {state.message && (
        <div
          role="alert"
          className={`mb-6 rounded-lg p-4 text-sm ${
            state.success
              ? "bg-green-50 text-green-800 border border-green-200"
              : "bg-red-50 text-red-800 border border-red-200"
          }`}
        >
          {state.message}
        </div>
      )}
 
      {/* Full Name */}
      <div className="mb-4">
        <label htmlFor="fullName" className="block text-sm font-medium mb-1">
          Full Name <span aria-hidden="true">*</span>
        </label>
        <input
          id="fullName"
          type="text"
          autoComplete="name"
          {...register("fullName")}
          aria-describedby={errors.fullName ? "fullName-error" : undefined}
          className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        {errors.fullName && (
          <p id="fullName-error" className="mt-1 text-xs text-red-600">
            {errors.fullName.message}
          </p>
        )}
      </div>
 
      {/* Email */}
      <div className="mb-4">
        <label htmlFor="email" className="block text-sm font-medium mb-1">
          Email <span aria-hidden="true">*</span>
        </label>
        <input
          id="email"
          type="email"
          autoComplete="email"
          {...register("email")}
          aria-describedby={errors.email ? "email-error" : undefined}
          className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        {errors.email && (
          <p id="email-error" className="mt-1 text-xs text-red-600">
            {errors.email.message}
          </p>
        )}
      </div>
 
      {/* Company (optional) */}
      <div className="mb-4">
        <label htmlFor="company" className="block text-sm font-medium mb-1">
          Company <span className="text-gray-400 text-xs">(optional)</span>
        </label>
        <input
          id="company"
          type="text"
          autoComplete="organization"
          {...register("company")}
          className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        {errors.company && (
          <p className="mt-1 text-xs text-red-600">{errors.company.message}</p>
        )}
      </div>
 
      {/* Service */}
      <div className="mb-4">
        <label htmlFor="service" className="block text-sm font-medium mb-1">
          Service Interest <span aria-hidden="true">*</span>
        </label>
        <select
          id="service"
          {...register("service")}
          aria-describedby={errors.service ? "service-error" : undefined}
          className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        >
          <option value="">Select a service…</option>
          {SERVICE_OPTIONS.map((v) => (
            <option key={v} value={v}>
              {SERVICE_LABELS[v]}
            </option>
          ))}
        </select>
        {errors.service && (
          <p id="service-error" className="mt-1 text-xs text-red-600">
            {errors.service.message}
          </p>
        )}
      </div>
 
      {/* Budget */}
      <div className="mb-4">
        <label htmlFor="budget" className="block text-sm font-medium mb-1">
          Budget Range <span aria-hidden="true">*</span>
        </label>
        <select
          id="budget"
          {...register("budget")}
          aria-describedby={errors.budget ? "budget-error" : undefined}
          className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        >
          <option value="">Select a range…</option>
          {BUDGET_OPTIONS.map((v) => (
            <option key={v} value={v}>
              {BUDGET_LABELS[v]}
            </option>
          ))}
        </select>
        {errors.budget && (
          <p id="budget-error" className="mt-1 text-xs text-red-600">
            {errors.budget.message}
          </p>
        )}
      </div>
 
      {/* Message */}
      <div className="mb-4">
        <label htmlFor="message" className="block text-sm font-medium mb-1">
          Message <span aria-hidden="true">*</span>
        </label>
        <textarea
          id="message"
          rows={5}
          {...register("message")}
          aria-describedby={errors.message ? "message-error" : undefined}
          className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
        />
        {errors.message && (
          <p id="message-error" className="mt-1 text-xs text-red-600">
            {errors.message.message}
          </p>
        )}
      </div>
 
      {/* Attachment */}
      <div className="mb-6">
        <label htmlFor="attachment" className="block text-sm font-medium mb-1">
          Attachment <span className="text-gray-400 text-xs">(PDF, max 5 MB)</span>
        </label>
        <input
          id="attachment"
          type="file"
          accept="application/pdf"
          {...register("attachment")}
          className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
        />
        {errors.attachment && (
          <p className="mt-1 text-xs text-red-600">{errors.attachment.message}</p>
        )}
      </div>
 
      <button
        type="submit"
        disabled={isPending}
        className="w-full rounded-md bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
      >
        {isPending ? "Sending…" : "Send Proposal Request"}
      </button>
    </form>
  );
}

Step 5: Wire It Into a Page

// src/app/contact/page.tsx
import { ProposalForm } from "@/components/ProposalForm";
 
export const metadata = {
  title: "Request a Proposal",
  description: "Tell us about your project and we will get back to you within 24 hours.",
};
 
export default function ContactPage() {
  return (
    <main className="mx-auto max-w-lg px-4 py-16">
      <h1 className="text-2xl font-bold mb-2">Request a Proposal</h1>
      <p className="text-gray-500 text-sm mb-8">
        Fill in the details below. We will respond within one business day.
      </p>
      <ProposalForm />
    </main>
  );
}

How the Validation Layers Work Together

Understanding the two-layer architecture prevents bugs:

Layer 1 — Client (React Hook Form + Zod resolver): When the user clicks Submit, handleSubmit calls zodResolver(proposalSchema) before the Server Action fires. Errors populate the RHF error map immediately, with no network round-trip. The user sees inline error messages under the offending fields.

Layer 2 — Server (Server Action + serverSchema): Even if someone disables JavaScript or crafts a raw fetch request to bypass the client, the Server Action re-validates with serverSchema.safeParse(). Errors come back in the structured state.errors object, and the useEffect in the component syncs them back into RHF's error state so they display in exactly the same way as client errors.

The only difference between the two schemas is the attachment field: on the client z.instanceof(File) works because File is a browser global; on the server we replace it with z.instanceof(Blob) which Node 20+ provides natively.

Step 6: Add Optimistic Feedback

For forms that take more than a second to process — for example, if you send a confirmation email — you can show an optimistic success message immediately while the real submission completes in the background.

// In ProposalForm.tsx — replace the submit button section
import { useOptimistic } from "react";
 
// Inside the component:
const [optimisticSent, setOptimisticSent] = useOptimistic(false);
 
const onSubmit = handleSubmit(() => {
  setOptimisticSent(true);
  formRef.current?.requestSubmit();
});
 
// In the JSX:
{optimisticSent && !state.success && (
  <p className="text-blue-600 text-sm mb-4 animate-pulse">
    Sending your request…
  </p>
)}

useOptimistic from React 19 flips to true the moment the user clicks submit and automatically resets once the Server Action resolves. This keeps the UI responsive even on slow connections.

Step 7: Custom Validation Rules

Zod's .refine() and .superRefine() methods let you add business rules that go beyond format checks.

// Example: require a company name for enterprise budgets
export const proposalSchema = z
  .object({
    // ... fields as before
  })
  .refine(
    (data) => {
      if (data.budget === "above-100k" && !data.company) {
        return false;
      }
      return true;
    },
    {
      message: "Company name is required for enterprise budgets",
      path: ["company"], // attach the error to the company field
    }
  );

Cross-field validation like this is where Zod shines. The path option tells React Hook Form which field to highlight, so the error appears under "Company" even though the rule compares two fields.

Testing Your Implementation

Start the development server:

pnpm dev

Then navigate to http://localhost:3000/contact and test these scenarios:

Happy path: Fill all required fields, attach a valid PDF under 5 MB, submit. You should see the success banner.

Client validation: Submit with an empty name. The error should appear instantly with no loading spinner and no network request.

Server validation bypass: Open DevTools, go to Network, right-click the form submission and disable JavaScript. Submit via the native form action. The page should reload with the same field-level errors rendered server-side.

File rejection: Attach a .jpg or a file larger than 5 MB. The error should appear on the client before the form is submitted.

Troubleshooting

setError is not a function or the server errors do not appear: Confirm your useEffect dependency array includes [state, setError, reset]. Missing state means the effect never re-runs after the action resolves.

The file input does not reset after success: HTML file inputs are uncontrolled by design. The formRef.current?.reset() call in the useEffect resets the native form element, which clears the file picker. Do not try to reset the file input through RHF alone.

TypeScript error on z.instanceof(File) in the server module: If your Server Action accidentally imports proposalSchema (which uses z.instanceof(File)), Node will throw because File is not defined server-side. Keep the serverSchema with z.instanceof(Blob) in the action file and only import proposalSchema in Client Components.

safeParse returns success but parsed.data is missing the email transform: Zod v4 applies transforms during parse()/safeParse(). Confirm you are reading parsed.data.email (the transformed value) rather than raw.email (the original string).

Next Steps

With the foundation in place, consider these enhancements:

  • Persist to a database using Drizzle ORM or Prisma with the typed parsed.data object — both accept the inferred Zod type directly
  • Send email notifications with Resend's @react-email/components on successful submission
  • Add CAPTCHA using Cloudflare Turnstile (@marsidev/react-turnstile) to prevent spam without impacting real users
  • Multi-step form — split the fields across pages, storing partial state in sessionStorage or a React context and combining at the final submit
  • Rate limiting — wrap the Server Action with Upstash Redis or Arcjet to prevent abuse

Conclusion

React Hook Form v8 and Zod v4 are not new technologies — they have been mature for years — but their integration with Next.js 15 Server Actions finally eliminates the awkward gap between client validation and server validation. The schema defined in lib/schemas/proposal.ts is the single source of truth: it drives the TypeScript types, the client error messages, the server-side re-check, and (with a thin adapter) your database insert shape.

The pattern scales cleanly. A checkout form, a multi-tenant onboarding flow, or a heavily validated admin dashboard all follow the same structure: schema in lib/, action in app/actions/, component in components/. Keep those three pieces separate and you will never find yourself chasing a validation bug that only reproduces in production.