Type-safe form architecture for Next.js product engineers. React Hook Form + Zod + shadcn/ui Form primitives, with one Zod schema driving validation, types, and the API contract. Apply when building any create/edit form, multi-step wizard, or form that submits via Server Action or React Query mutation. Covers field wiring, server-error mapping, field arrays, async validation, and the loading/disabled states most forms get wrong.
install
curl -fsSL https://skills.nabinkhair.com.np/skills/form-stack/llms.txt -o form-stack.md Form Stack
A single, repeatable pattern for every form in a product app. Built on React Hook Form (state + validation lifecycle), Zod (one schema for validation and types), and shadcn/ui Form primitives (accessible field wiring).
The core idea: the Zod schema is the single source of truth. It validates the form, infers the TypeScript types, and matches the API contract. You never write a form type by hand.
This skill pairs with product-stack — reuse the exact createXSchema you defined in schemas/ to drive the form, and submit through the same React Query hooks.
Principles
- One schema, three jobs — validation, types (
z.infer), and the request body all come from the same Zod schema. Never duplicate the shape. - Uncontrolled by default — RHF registers inputs and tracks them via refs. Don’t lift every field into
useState; let the form own its state. - Validate on the right trigger —
onBlurfor create forms,onChangeonly once a field has errored. Don’t validate aggressively on every keystroke from the start. - Server errors map back to fields — a 409 “email taken” belongs on the email field via
setError, not just a toast. - Submit is one async function — disable the form while it runs, surface field/root errors on failure, reset or redirect on success.
- The schema is the contract — if the API rejects a payload the schema accepted, the schema is wrong. Fix it there, not with ad-hoc checks.
Setup
pnpm add react-hook-form zod @hookform/resolvers
npx shadcn@latest add form input button
shadcn’s form component gives you Form, FormField, FormItem, FormLabel, FormControl, FormDescription, and FormMessage — thin wrappers over RHF’s Controller that wire up id, aria-describedby, and aria-invalid automatically. Use them; do not hand-roll labels and error text.
The Schema (source of truth)
Reuse the resource schema from schemas/ (see product-stack Layer 3). The same schema validates the form and the API route.
// schemas/project.ts
import { z } from "zod";
export const createProjectSchema = z.object({
name: z.string().min(1, "Name is required").max(100),
description: z.string().max(500).optional(),
status: z.enum(["draft", "active", "archived"]).default("draft"),
visibility: z.enum(["public", "private"]),
tags: z.array(z.string().min(1)).max(10).default([]),
});
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
Rule: form components import CreateProjectInput — they never define a ProjectFormValues interface by hand.
The Base Form Pattern
Every form follows this shape. Memorize it.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
createProjectSchema,
type CreateProjectInput,
} from "@/schemas/project";
export function ProjectForm() {
const form = useForm<CreateProjectInput>({
resolver: zodResolver(createProjectSchema),
mode: "onBlur",
defaultValues: {
name: "",
description: "",
status: "draft",
visibility: "private",
tags: [],
},
});
async function onSubmit(values: CreateProjectInput) {
// submit logic — see "Submitting" below
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Acme dashboard" {...field} />
</FormControl>
<FormDescription>Shown across your workspace.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Saving..." : "Create project"}
</Button>
</form>
</Form>
);
}
Rules:
- Always provide
defaultValuesfor every field. Missing defaults make inputs uncontrolled→controlled and React warns. Use"",[], or the enum default — neverundefinedfor a rendered field. resolver: zodResolver(schema)— one line connects Zod to RHF.{...field}spreadsvalue,onChange,onBlur,name,refonto the input. Don’t destructure them manually unless you need to wrap one.<FormMessage />renders the field’s Zod error automatically. Never write{errors.name?.message}yourself.- Submit through
form.handleSubmit(onSubmit)— it validates first and only callsonSubmitwith typed, valid data.
Validation Mode
useForm({
mode: "onBlur", // validate when a field loses focus (good default for create)
reValidateMode: "onChange", // after first error, re-check on every change
});
| Form type | mode | Why |
|---|---|---|
| Create / long form | onBlur | Don’t yell before the user finishes a field |
| Edit / settings | onChange | Instant feedback, user knows the existing data |
| Search / filter | onChange | Live filtering |
| Single-action submit | onSubmit | Only validate when they commit |
reValidateMode: "onChange" is almost always right: silent until the first error, then live until it’s fixed.
Submitting
Two valid paths. Pick by who consumes the mutation (same rule as product-stack).
Path A — React Query mutation (client form → API route)
Reuse the hook from your resource. The hook owns the toast and cache invalidation; the form owns field state.
import { useCreateProject } from "@/hooks/use-projects";
export function ProjectForm({ onSuccess }: { onSuccess?: () => void }) {
const form = useForm<CreateProjectInput>({
/* ...as above */
});
const createProject = useCreateProject();
async function onSubmit(values: CreateProjectInput) {
try {
await createProject.mutateAsync(values);
form.reset();
onSuccess?.();
} catch (error) {
applyServerErrors(form, error); // map field errors — see below
}
}
const isSubmitting = createProject.isPending;
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* fields */}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Create project"}
</Button>
</form>
</Form>
);
}
Use mutateAsync (not mutate) inside the submit handler so you can await and try/catch to map field errors. Drive the disabled state from mutation.isPending, not formState.isSubmitting, when submitting via React Query.
Path B — Server Action (form is the only consumer)
"use client";
import { useTransition } from "react";
import { createProjectAction } from "@/app/actions/project";
export function ProjectForm() {
const form = useForm<CreateProjectInput>({ /* ... */ });
const [isPending, startTransition] = useTransition();
function onSubmit(values: CreateProjectInput) {
startTransition(async () => {
const result = await createProjectAction(values);
if (result?.fieldErrors) {
for (const [field, message] of Object.entries(result.fieldErrors)) {
form.setError(field as keyof CreateProjectInput, { message });
}
return;
}
form.reset();
});
}
return (/* ...Form... */);
}
The Server Action re-validates with the same schema and returns structured fieldErrors — never trust client validation alone.
// app/actions/project.ts
"use server";
import { createProjectSchema } from "@/schemas/project";
export async function createProjectAction(input: unknown) {
const parsed = createProjectSchema.safeParse(input);
if (!parsed.success) {
return { fieldErrors: z.flattenError(parsed.error).fieldErrors };
}
// ...persist, revalidatePath
}
Mapping Server Errors to Fields
A toast for “email already taken” is a worse UX than a red message under the email field. Map known server errors back onto the form, fall back to a root error for everything else.
import type { UseFormReturn, FieldValues, Path } from "react-hook-form";
import { AxiosError } from "axios";
export function applyServerErrors<T extends FieldValues>(
form: UseFormReturn<T>,
error: unknown,
) {
if (error instanceof AxiosError) {
const data = error.response?.data as
| { fieldErrors?: Record<string, string[]>; error?: string }
| undefined;
// Per-field errors (e.g. from z.flattenError on the server)
if (data?.fieldErrors) {
for (const [field, messages] of Object.entries(data.fieldErrors)) {
form.setError(field as Path<T>, { message: messages[0] });
}
return;
}
// Single field conflict, e.g. 409 unique violation
if (error.response?.status === 409) {
form.setError("name" as Path<T>, {
message: data?.error ?? "Already exists",
});
return;
}
}
// Fallback: form-level error, render with <FormMessage /> on root
form.setError("root.serverError", {
message: "Something went wrong. Please try again.",
});
}
Render the root error above the submit button:
{
form.formState.errors.root?.serverError && (
<p className="text-sm text-destructive">
{form.formState.errors.root.serverError.message}
</p>
);
}
Rules:
setErrorfor known fields;setError("root.serverError", ...)for unknown failures.rooterrors clear on next submit automatically; field errors clear when the field changes.- Server validation must mirror the client schema. The client schema is UX; the server schema is the gate.
Non-Input Fields (Select, Checkbox, Switch, RadioGroup)
These don’t accept {...field} directly — they expose value/onValueChange (or checked/onCheckedChange). Wire them explicitly inside the render callback.
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{
/* Checkbox / Switch — boolean fields */
}
<FormField
control={form.control}
name="isPublic"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Public project</FormLabel>
<FormDescription>Anyone with the link can view.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>;
Rule: keep <FormControl> wrapping the trigger of compound components (Select, Popover) so the label/aria-describedby wiring stays correct.
Field Arrays (repeatable rows)
Use useFieldArray for dynamic lists (tags, team members, line items). Never manage an array of inputs with useState alongside RHF.
import { useFieldArray } from "react-hook-form";
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "members", // schema: members: z.array(z.object({ email: z.string().email() }))
});
return (
<div className="space-y-3">
{fields.map((item, index) => (
<div key={item.id} className="flex gap-2">
<FormField
control={form.control}
name={`members.${index}.email`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input placeholder="teammate@acme.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="button" variant="ghost" onClick={() => remove(index)}>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => append({ email: "" })}
>
Add member
</Button>
</div>
);
Rules:
- Use
item.id(RHF’s stable generated key) forkey, not the array index. Index keys break on reorder/remove. - Name fields with template paths:
members.${index}.email. - Append a fully-shaped object (
{ email: "" }), not{}, so defaults stay consistent.
Async Validation (e.g. “is this slug available?”)
Two options. Prefer Zod’s async refine for one-off checks; debounce a watched value for live feedback.
// In the schema — runs on submit and on validation triggers
export const createProjectSchema = z.object({
slug: z
.string()
.min(1)
.refine(
async (slug) => {
const res = await fetch(`/api/projects/slug-available?slug=${slug}`);
const { available } = await res.json();
return available;
},
{ message: "That slug is taken" },
),
});
For live feedback without hammering the API, debounce a watched field and call form.setError/form.clearErrors manually:
const slug = form.watch("slug");
useEffect(() => {
if (!slug) return;
const t = setTimeout(async () => {
const res = await fetch(`/api/projects/slug-available?slug=${slug}`);
const { available } = await res.json();
if (!available) form.setError("slug", { message: "That slug is taken" });
else form.clearErrors("slug");
}, 400);
return () => clearTimeout(t);
}, [slug, form]);
Rule: always debounce network-backed validation (300–500ms). Always still validate on the server — async client checks are a convenience, not a guarantee.
Edit Forms (pre-filled data)
For edit forms, seed defaultValues from fetched data and reset when it loads. Pairs naturally with optimistic-cache-pattern — open with cached values, reset() when full data resolves.
const { data: project } = useProject(id);
const form = useForm<UpdateProjectInput>({
resolver: zodResolver(updateProjectSchema),
defaultValues: project ?? { name: "", description: "" },
});
// Re-seed when data arrives or changes
useEffect(() => {
if (project) form.reset(project);
}, [project, form]);
Rules:
form.reset(data)is the correct way to load values async — notsetValueper field.- Use
form.formState.isDirtyto disable “Save” until the user actually changed something. - Send only changed fields if your API supports
PATCH:form.formState.dirtyFieldstells you which.
Multi-Step / Wizard Forms
One useForm instance spanning all steps. Validate only the current step’s fields before advancing.
const steps = [
["name", "description"],
["status", "visibility"],
["tags"],
] as const;
const [step, setStep] = useState(0);
async function next() {
const valid = await form.trigger(steps[step]); // validate only this step's fields
if (valid) setStep((s) => s + 1);
}
Rules:
- Keep a single form instance; don’t create one per step (you’d lose state between steps).
form.trigger(fieldNames)validates a subset on demand.- Only call
handleSubmiton the final step. - Persist
form.getValues()tosessionStorageif steps can be navigated away from.
Loading & Disabled States
The states most forms get wrong:
const isSubmitting = mutation.isPending; // or form.formState.isSubmitting for actions
<fieldset disabled={isSubmitting} className="space-y-6 disabled:opacity-70">
{/* all fields disable together via fieldset */}
</fieldset>
<Button type="submit" disabled={isSubmitting || !form.formState.isDirty}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
Rules:
- Wrap fields in a
<fieldset disabled={isSubmitting}>to disable the whole form in one place. - Disable submit while pending and (for edit forms) when
!isDirty. - Don’t unmount the form on success if you show inline confirmation —
form.reset()instead.
Common Mistakes
- Hand-written form types — Always
z.infer<typeof schema>. Neverinterface ProjectFormValues. - Missing
defaultValues— Every rendered field needs a default ("",[], enum value). Omitting them triggers controlled/uncontrolled warnings. - Index as
useFieldArraykey — Usefield.id. Index keys corrupt state on remove/reorder. mutateinstead ofmutateAsyncin submit — You can’tawait/catchmutate, so server-error mapping silently fails.- Server errors only as toasts — Map known field errors with
setError; reserve toasts/root errors for unknown failures. - No server-side validation — Client validation is UX. The Server Action / route handler must re-validate with the same schema.
- Validating on
onChangefrom the start — Aggressive validation before the user finishes typing feels hostile. Default toonBlur+reValidateMode: onChange. setValueto load edit data field-by-field — Useform.reset(data).- Reading
errors.field?.messagemanually —<FormMessage />already does this with correctariawiring. - Lifting field values into
useState— Let RHF own form state. Onlywatchwhat you need to react to. - Forgetting
type="button"— Any non-submit button inside a<form>(add/remove row, next step) must betype="button"or it submits the form. - Not disabling submit while pending — Double-submits create duplicate records. Disable on
isPending.
Tech Stack
- React Hook Form — form state, validation lifecycle, field arrays
- Zod — one schema for validation + types + API contract
- @hookform/resolvers —
zodResolverbridge - shadcn/ui
Form— accessibleFormField/FormItem/FormControl/FormMessagewiring - TanStack React Query — mutation submit path (or Server Actions for app-only forms)
- Sonner — success/error toasts (in the hook, not the form)