Tech Stack

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/resolvers is the small adapter package that lets react-hook-form use a Zod schema as its validator, via zodResolver(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:

TypeScript
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:

TypeScript
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:

TypeScript
<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:

TypeScript
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:

TypeScript
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.

On this page