Back to Blog
Tutorial 9 min read

Mastering React Hook Form + Zod + Forge in 2026

S
Sarah ChenStaff Engineer, Forge · Published February 28, 2026

React Hook Form + Zod + Forge is the cleanest full-stack form pattern available for Next.js in 2026. React Hook Form handles client-side performance (uncontrolled inputs — no re-render on every keystroke). Zod handles type-safe schema validation shared between client and server. Forge handles the backend: secure storage, webhook delivery, and a response dashboard. This guide walks through the complete setup from schema definition to production-ready submission handling.

You'll need: react-hook-form, @hookform/resolvers, zod, and @forge/react.

1. Why This Stack Beats the Alternatives

React Hook Form is uncontrolled by default — inputs are not controlled by React state, so the component does not re-render on every keystroke. For forms with 10+ fields this is the difference between 60fps and janky input lag. Zod gives you a single schema that works identically on the client (via the resolver) and on the server (via Forge's server-side validation). You write the schema once and it enforces correctness at both ends.

2. Setting Up the Shared Zod Schema

Define your schema in a shared file that both the form component and your server action can import:

// lib/schemas/contact.ts
import { z } from "zod";

export const contactSchema = z.object({
  firstName:   z.string().min(1, "First name is required"),
  lastName:    z.string().min(1, "Last name is required"),
  workEmail:   z.string().email("Must be a valid work email"),
  company:     z.string().min(2, "Company name too short"),
  companySize: z.enum(["1-10", "11-50", "51-200", "201-1000", "1000+"]),
  message:     z.string().min(10, "Tell us more").max(1000),
  // UTM tracking — invisible to the user
  utm_source:   z.string().optional(),
  utm_campaign: z.string().optional(),
});

export type ContactFormData = z.infer<typeof contactSchema>;

3. Wiring Up React Hook Form with the Zod Resolver

"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { contactSchema, ContactFormData } from "@/lib/schemas/contact";

export function ContactForm() {
  const params = new URLSearchParams(
    typeof window !== "undefined" ? window.location.search : ""
  );

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting, isSubmitSuccessful },
  } = useForm<ContactFormData>({
    resolver: zodResolver(contactSchema),
    defaultValues: {
      companySize:  "11-50",
      utm_source:   params.get("utm_source")   ?? "",
      utm_campaign: params.get("utm_campaign") ?? "",
    },
  });

  const onSubmit = async (data: ContactFormData) => {
    const res = await fetch(
      `https://api.useforge.cloud/f/${process.env.NEXT_PUBLIC_FORGE_FORM_ID}/submit`,
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      }
    );
    if (!res.ok) throw new Error("Submission failed");
  };

  if (isSubmitSuccessful) {
    return (
      <div className="p-8 bg-green-50 border border-green-200 rounded-2xl text-center">
        <p className="text-green-700 font-semibold">Thanks! We&apos;ll be in touch soon.</p>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-5">
      {/* Hidden UTM fields */}
      <input type="hidden" {...register("utm_source")} />
      <input type="hidden" {...register("utm_campaign")} />

      {/* Visible fields go here */}

      <button type="submit" disabled={isSubmitting}
        className="w-full h-12 bg-blue-600 hover:bg-blue-500 text-white font-bold rounded-xl text-sm">
        {isSubmitting ? "Sending…" : "Submit"}
      </button>
    </form>
  );
}

4. Inline Error Handling — Never Use a Toast for Field Errors

The formState.errors object gives you field-level messages from Zod. Display them inline, directly below the input — toast notifications for field validation errors are a UX antipattern because users can't see which field failed after the toast fades.

// Reusable field wrapper pattern
function Field({
  label, error, children
}: { label: string; error?: string; children: React.ReactNode }) {
  return (
    <div>
      <label className="block text-sm font-semibold text-zinc-700 mb-1.5">{label}</label>
      {children}
      {error && <p className="text-red-500 text-xs mt-1">{error}</p>}
    </div>
  );
}

// Usage inside your form:
<Field label="Work email" error={errors.workEmail?.message}>
  <input
    {...register("workEmail")}
    type="email"
    className={`w-full h-11 px-4 rounded-xl border text-sm
      ${errors.workEmail ? "border-red-400 bg-red-50" : "border-zinc-300"}`}
  />
</Field>

5. Multi-Step Forms with Per-Step Validation

Use trigger(fields) to validate only the current step before advancing. The full payload is submitted on the final step. Set shouldUnregister: false so values from earlier steps are preserved in the form state when you navigate back.

const { trigger, handleSubmit } = useForm({
  resolver: zodResolver(contactSchema),
  shouldUnregister: false, // preserve values across steps
});

const [step, setStep] = useState(0);
const STEP_FIELDS = [
  ["firstName", "workEmail"],  // step 0
  ["company", "companySize"],  // step 1
  ["message"],                 // step 2 — submit here
] as const;

const next = async () => {
  const ok = await trigger(STEP_FIELDS[step] as string[]);
  if (ok) setStep(s => s + 1);
};

6. Performance Tips

  • Use mode: "onBlur" — validate on field exit, not on every keystroke
  • Lazy-import heavy fields (date pickers, file upload) with next/dynamic
  • Debounce async validation (e.g. email availability checks) at 400ms minimum
  • Wrap the form in <Suspense> if it lazy-loads to avoid hydration mismatches
  • Use useFormContext to share form state across deeply nested components without prop drilling

Build your first React form with Forge

Free tier: 4 forms, 100 submissions/month. No credit card. Full React SDK included.

Start building free

Ready to build?

Create your free Forge account. 4 forms, 100 submissions/month, forever free.

Start Free