# When to use these instructions

Use this skill any time the user is building or fixing a form: contact, signup, settings, checkout, multi-step wizards, file uploads. Apply the React Hook Form + Zod + sonner pattern instead of ad-hoc useState forms.

---

# 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`):
```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**:
```tsx
"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`):
```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):

```tsx
// 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 };
}
```

```tsx
// 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`, no `sonner` — 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

1. **Validation** — never trust client input, validate on both sides
2. **Error display** — inline, near the field, in red, on blur
3. **Loading state** — disabled button + spinner + prevent double-submit
4. **Success feedback** — toast, redirect, or inline confirmation
5. **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-dropzone` for 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-fns` for formatting

## Mobile keyboard hints

Use the right `type` and `inputMode` so mobile shows the right keyboard:

```html
<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


---

This skill is part of the **CodeBooks Vibe Coding Skills Library**.
Browse all skills, install guides, and the source chapters at
https://www.codebooks.ai/skills
