Forms & Validation
react-hook-form, zod, and @hookform/resolvers - how form state and validation are wired together.
The three libraries and what each does
- react-hook-form manages form state (values, touched/dirty fields, submit status) without re-rendering the whole form on every keystroke.
- Zod describes the shape your data must have and validates it -
z.string().min(1),z.enum([...]), etc. - and can derive a TypeScript type from that shape automatically (z.infer<typeof schema>), so your types and your runtime validation can never drift apart. @hookform/resolversis the small adapter package that lets react-hook-form use a Zod schema as its validator, viazodResolver(schema).
None of these are Next.js-specific - this is a general pattern for any React app that has non-trivial forms.
The pattern used throughout this codebase
Every form-heavy component (OrderForm, LoginForm, SettingsForm, JoinRequestForm, VaultEntryDialog) follows the same shape. Using src/components/orders/OrderForm.tsx as the concrete example:
1. Define a Zod schema at the top of the file. OrderForm submits a list of
items, so its schema wraps a per-item schema in an array:
const itemSchema = z.object({
vendor: z.string().min(1, "Vendor is required").max(200),
link: z.string().url("Enter a valid URL").max(500),
quantity: z
.string()
.min(1, "Required")
.regex(/^\d+$/, "Whole number")
.refine((v) => Number(v) >= 1 && Number(v) <= 9999, "Between 1 and 9999"),
// ...
});
const formSchema = z.object({
items: z.array(itemSchema).min(1).max(MAX_ORDER_BATCH_ITEMS),
});
type FormValues = z.infer<typeof formSchema>;Errors are reported per row, at paths like items.2.link, so one bad row in a
batch highlights that row rather than failing the whole form opaquely.
When validation depends on more than one field at once, use .superRefine(),
which can attach an issue to any field's path based on the whole object. A
field-level z.string().min(1) can't express that. orderAssignSchema in
src/lib/validation.ts is the current example: an STF assignment requires a
bucket, a Gift assignment does not.
2. Wire the schema into useForm:
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { fundType: undefined, vendor: "", quantity: "1" /* ... */ },
});3. Render fields through shadcn's Form primitives (src/components/ui/form.tsx), which connect each FormField to react-hook-form's state and automatically render Zod's error message via FormMessage:
<FormField
control={form.control}
name="vendor"
render={({ field }) => (
<FormItem>
<FormLabel>Vendor</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>4. On submit, call an API route directly (not a Next.js Server Action) and report the result via a sonner toast:
async function onSubmit(values: FormValues) {
setSubmitting(true);
try {
const res = await fetch("/api/orders", {
method: "POST",
body: JSON.stringify({ items: values.items }),
});
if (!res.ok) throw new Error(await res.text());
toast.success("Order submitted");
router.push("/orders");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Something went wrong");
} finally {
setSubmitting(false);
}
}Why string fields for numbers?
Notice quantity and unitCost are z.string() with a .regex()/.refine() check, not z.number(). This is deliberate: HTML <input> values are always strings, and coercing on every keystroke fights the input's cursor position and empty-string state. The string is validated/parsed at submit time (and converted back to cents/integers before hitting the API) rather than during typing.
Where else this pattern shows up
If you're adding a new form, don't design a new pattern - copy the shape from the closest existing example: src/components/auth/LoginForm.tsx (simple, single-object form), or src/components/orders/OrderForm.tsx (repeatable rows via useFieldArray, edit-vs-create mode via an optional initialOrder prop).
Repeatable rows
OrderForm lets a member submit many items at once. It uses react-hook-form's
useFieldArray over the items array, rendering one set of fields per row:
const { fields, append, remove } = useFieldArray({ control: form.control, name: "items" });Two rules keep this from feeling heavier than a single-item form:
- A lone row renders as a plain form, with no card chrome, index heading or remove button. The batch affordances only appear once there is more than one row, so the common case is unchanged.
- Field names are template paths (
`items.${index}.vendor`), which is what lets Zod's per-row issue paths line up with the right input.
PasteItemsPanel is the bulk entry path: it parses tab- or comma-separated rows
pasted from a spreadsheet into item objects and appends them all at once. Its
parser is pure and unit-tested in src/components/orders/paste-items.test.ts.