Mastering React Hook Form + Zod + Forge in 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'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
useFormContextto 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 freeReady to build?
Create your free Forge account. 4 forms, 100 submissions/month, forever free.
Start Free