Forms
Most beginner forms break in five places: no validation, no error display, no loading state, no success feedback, lost data on refresh. This skill closes all five — and gives you the production stack to do it cleanly.
When to use this
- The user is building any form (contact, signup, settings, checkout, search).
- An existing form is missing validation or error feedback.
- The user is wiring up file uploads, multi-step wizards, or searchable selects.
Stack-aware
This skill works for two common Next.js paths:
- Stack A (managed): React Hook Form + Zod + shadcn/ui — the heavyweight production stack for complex forms with many fields, dynamic UIs, and per-field validation.
- Stack B (vanilla): Server Actions + FormData + Zod — the lightweight modern Next.js path for simple forms (newsletter, contact, search). No client JS needed.
Both ship great forms. Pick A when you have 5+ fields, branching logic, or live validation; pick B when you have 1-3 fields and just need it to work. See "When NOT to use React Hook Form" below.
The opinionated stack
For React / Next.js with React Hook Form (Stack A), always use:
| Concern | Pick | Why |
|---|---|---|
| Form state | React Hook Form | No re-renders, tiny, integrates with everything |
| Validation | Zod | Schema-first, type-safe, share between client/server |
| UI | shadcn/ui Form components | Accessible labels, error display, focus management |
| Toasts | sonner | Beautiful out of the box, tiny |
| Server validation | Zod (same schema) | Single source of truth |
For non-React stacks:
- Vue / Nuxt → VeeValidate + Zod or yup
- Svelte → Felte + Zod
- Vanilla → native HTML5 validation + a small validator function
The standard prompt template
Build a [contact] form using React Hook Form, Zod, and shadcn/ui. Fields:
- name (required, min 2 chars)
- email (required, valid email)
- subject (required, dropdown: General, Support, Sales)
- message (required, min 10 chars, max 1000 chars)
Requirements:
- Single source of truth Zod schema (export it for reuse on the server)
- Inline error messages below each field (red text, appears on blur)
- Submit button shows "Sending..." with a spinner while pending
- Disable submit while pending to prevent double-submit
- On success: clear the form and show a green sonner toast "Message sent!"
- On error: show a red sonner toast with the error message
- Submit to a /api/contact route handler that re-validates with the same Zod
schema and returns { ok: true } or { error }
The pattern in code
Schema first (lib/schemas.ts):
import { z } from "zod";
export const contactSchema = z.object({
name: z.string().min(2, "Name is required"),
email: z.string().email("Enter a valid email"),
subject: z.enum(["general", "support", "sales"]),
message: z.string().min(10, "Message is too short").max(1000, "Too long"),
});
export type ContactInput = z.infer<typeof contactSchema>;
Form component:
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { contactSchema, type ContactInput } from "@/lib/schemas";
export function ContactForm() {
const form = useForm<ContactInput>({
resolver: zodResolver(contactSchema),
defaultValues: { name: "", email: "", subject: "general", message: "" },
});
async function onSubmit(values: ContactInput) {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (res.ok) {
toast.success("Message sent!");
form.reset();
} else {
toast.error("Something went wrong. Please try again.");
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* shadcn FormField components for each field */}
</form>
</Form>
);
}
Server validation (app/api/contact/route.ts):
import { contactSchema } from "@/lib/schemas";
export async function POST(req: Request) {
const body = await req.json();
const parsed = contactSchema.safeParse(body);
if (!parsed.success) {
return Response.json({ error: parsed.error.flatten() }, { status: 400 });
}
// ... do the thing (send email, save to DB, etc.)
return Response.json({ ok: true });
}
When NOT to use React Hook Form [Stack B]
React Hook Form + Zod is overkill for simple forms. The modern Next.js path for newsletter signups, contact forms, search inputs, and 1-3 field forms is Server Actions + FormData + Zod — no client-side JS for state, no extra dependencies, and the form still validates on the server with the same Zod schema.
The vanilla pattern (use this when you have 1-3 fields and no live validation):
// app/newsletter/actions.ts
"use server";
import { z } from "zod";
import { supabase } from "@/lib/supabase";
const schema = z.object({
email: z.string().email("Enter a valid email"),
});
export async function subscribe(prevState: unknown, formData: FormData) {
const parsed = schema.safeParse({ email: formData.get("email") });
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors.email?.[0] };
}
const { error } = await supabase
.from("subscribers")
.insert({ email: parsed.data.email });
if (error?.code === "23505") return { error: "Already subscribed!" };
if (error) return { error: "Something went wrong. Try again." };
return { ok: true };
}
// app/newsletter/NewsletterForm.tsx
"use client";
import { useActionState } from "react";
import { subscribe } from "./actions";
export function NewsletterForm() {
const [state, formAction, pending] = useActionState(subscribe, null);
if (state?.ok) {
return <p className="text-teal-dark">Thanks! Check your inbox.</p>;
}
return (
<form action={formAction} className="flex gap-2">
<input
name="email"
type="email"
required
placeholder="you@example.com"
className="flex-1 px-4 py-2 rounded-lg border border-border"
/>
<button
type="submit"
disabled={pending}
className="px-4 py-2 rounded-lg bg-purple text-white disabled:opacity-50"
>
{pending ? "..." : "Subscribe"}
</button>
{state?.error && (
<p className="text-pink-dark text-sm mt-2">{state.error}</p>
)}
</form>
);
}
What you get for free:
- The form works without JavaScript (progressive enhancement)
- The Zod schema runs on the server only — clients can't bypass it
- Pending state via
useActionState's third return value - No
react-hook-form, no@hookform/resolvers, nosonner— just React + Zod
Use the vanilla pattern when:
- The form has 1-3 fields
- You don't need live per-field validation (errors on submit are fine)
- You don't need branching/conditional fields
- The form is non-blocking (newsletter, contact, search, single-action settings)
Use React Hook Form (Stack A) when:
- The form has 5+ fields
- You need per-field live validation (on blur)
- You have conditional/branching logic ("if checked, show these fields")
- You're building a multi-step wizard
- You need array fields (
useFieldArray) - You want optimistic UI with rollback
Mix both in the same project. Newsletter at the bottom of every page → vanilla. Onboarding wizard → React Hook Form. The two paths don't conflict.
The five things every form needs
- Validation — never trust client input, validate on both sides
- Error display — inline, near the field, in red, on blur
- Loading state — disabled button + spinner + prevent double-submit
- Success feedback — toast, redirect, or inline confirmation
- Failure recovery — keep input on error, never reset on failure
Field-specific recipes
Email + password (signup)
- Password requirements: 8+ chars, at least one number
- Strength indicator below the field (turns green when valid)
- Confirm password must match (use Zod
.refine()) - Use
<input type="password" autoComplete="new-password">
File upload
- Use
react-dropzonefor drag & drop - Validate file type and size BEFORE upload
- Show preview for images
- Upload to Supabase Storage / Cloudflare R2 / UploadThing
- Show progress bar for large files
- Save the URL to your DB, NOT the file itself
Multi-step wizard
- Each step is its own RHF form OR a single form with conditional sections
- Validate each step before allowing Next
- Allow Back without losing data (store in component state)
- Show a progress indicator at the top (
Step 2 of 4) - On final submit, send everything in one request
Searchable combobox
- Use shadcn
<Command>+<Popover> - Filter options as the user types
- Highlight the matching substring
- Support keyboard navigation (arrows + enter)
Date picker
- Use shadcn
<Calendar>inside a<Popover> - Store dates as ISO strings in the form, not Date objects
- Use
date-fnsfor formatting
Mobile keyboard hints
Use the right type and inputMode so mobile shows the right keyboard:
<input type="email" inputMode="email" autoComplete="email">
<input type="tel" inputMode="tel" autoComplete="tel">
<input type="number" inputMode="numeric">
<input type="text" inputMode="decimal"> <!-- for prices -->
<input type="url" inputMode="url">
<input type="search" inputMode="search">
What good looks like
- Tabbing through the form goes in the right order
- Errors appear on blur, not only on submit
- Pressing Enter submits the form
- Submit button is disabled during pending state
- On success, the form clears and the user sees confirmation
- On error, the user's input is preserved
- Mobile keyboards match the input type
- All inputs have associated
<label>elements
Common mistakes to avoid
- No server validation. A determined user will bypass client checks.
- Validation only on submit. Validate on blur for instant feedback.
- Generic error messages. "Invalid input" is useless. Say which field.
- Losing data on error. Never reset the form on failure.
- Forgetting accessible labels. Every input needs a real
<label>. - Auto-submitting forms. Always require explicit click or Enter.
- Storing files in the database. Use object storage and store the URL.
- Custom select dropdowns that break keyboard nav. Use shadcn
<Select>.
Going deeper
- Forms & Validation: https://www.codebooks.ai/forms
- Notifications & Toasts: https://www.codebooks.ai/notifications
- File Uploads: https://www.codebooks.ai/file-uploads
- API Integration: https://www.codebooks.ai/api-integration
