//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
These skill recipes are designed for Claude Code but also work with other AI coding agents that support skill/instruction files.
The complete SKILL.md content you can copy into .claude/skills/forms-validation/SKILL.md:
---
name: forms-validation-expert
description: "Advanced form handling with React Hook Form, Zod, and Server Actions. Use when asked to: form help, validation, Zod schema, form pattern, multi-step form, file upload form, react-hook-form, server action form, form errors."
allowed-tools: "Read, Write, Edit, Glob, Grep, Bash(npm:*), Bash(npx:*), Agent"
---
# Forms & Validation Expert
You are a forms and validation expert for React and Next.js. You build forms that are type-safe, accessible, progressively enhanced, and user-friendly.
## Validation Strategy Decision Tree
1. **Is this a Server Action form (no client JS required)?**
- Yes -> Zod validation in the Server Action + useActionState
- No -> Continue
2. **Does the form have complex client-side interactions?**
- Yes (conditional fields, multi-step, dynamic arrays) -> React Hook Form + Zod
- No (simple form, few fields) -> Server Action form or native HTML validation
3. **Are you using shadcn/ui?**
- Yes -> Use shadcn Form component (wraps React Hook Form)
- No -> Use React Hook Form directly
## Zod Schema Patterns
### Basic Schema
```tsx
import \{ z \} from "zod";
const UserSchema = z.object(\{
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
age: z.coerce.number().min(13, "Must be at least 13").max(120),
role: z.enum(["admin", "user", "moderator"]),
bio: z.string().max(500).optional(),
\});
type User = z.infer<typeof UserSchema>;const PaymentSchema = z.discriminatedUnion("method", [
z.object(\{
method: z.literal("card"),
cardNumber: z.string().regex(/^\d\{16\}$/, "Must be 16 digits"),
expiry: z.string().regex(/^\d\{2\}\/\d\{2\}$/, "MM/YY format"),
cvv: z.string().regex(/^\d\{3,4\}$/, "3 or 4 digits"),
\}),
z.object(\{
method: z.literal("paypal"),
paypalEmail: z.string().email(),
\}),
z.object(\{
method: z.literal("bank"),
accountNumber: z.string().min(8),
routingNumber: z.string().length(9),
\}),
]);const SignupSchema = z.object(\{
username: z
.string()
.min(3)
.refine(async (val) => \{
const exists = await checkUsernameExists(val);
return !exists;
\}, "Username is already taken"),
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
\}).refine((data) => data.password === data.confirmPassword, \{
message: "Passwords do not match",
path: ["confirmPassword"],
\});const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const UploadSchema = z.object(\{
avatar: z
.instanceof(File)
.refine((f) => f.size <= MAX_FILE_SIZE, "File must be under 5MB")
.refine(
(f) => ACCEPTED_TYPES.includes(f.type),
"Only JPEG, PNG, and WebP are accepted"
),
\});"use client";
import \{ useForm \} from "react-hook-form";
import \{ zodResolver \} from "@hookform/resolvers/zod";
import \{ z \} from "zod";
const schema = z.object(\{
name: z.string().min(2),
email: z.string().email(),
\});
type FormData = z.infer<typeof schema>;
export function ContactForm() \{
const \{
register,
handleSubmit,
formState: \{ errors, isSubmitting \},
reset,
\} = useForm<FormData>(\{
resolver: zodResolver(schema),
defaultValues: \{ name: "", email: "" \},
\});
const onSubmit = async (data: FormData) => \{
await submitToApi(data);
reset();
\};
return (
<form onSubmit=\{handleSubmit(onSubmit)\} noValidate>
<div>
<label htmlFor="name">Name</label>
<input id="name" \{...register("name")\} aria-invalid=\{!!errors.name\} />
\{errors.name && (
<p role="alert" className="text-sm text-red-500">
\{errors.name.message\}
</p>
)\}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
\{...register("email")\}
aria-invalid=\{!!errors.email\}
/>
\{errors.email && (
<p role="alert" className="text-sm text-red-500">
\{errors.email.message\}
</p>
)\}
</div>
<button type="submit" disabled=\{isSubmitting\}>
\{isSubmitting ? "Sending..." : "Submit"\}
</button>
</form>
);
\}// actions.ts
"use server";
import \{ z \} from "zod";
const ContactSchema = z.object(\{
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10).max(1000),
\});
type FormState = \{
errors?: Record<string, string[]>;
message?: string;
success: boolean;
\};
export async function submitContact(
prevState: FormState,
formData: FormData
): Promise<FormState> \{
const raw = Object.fromEntries(formData);
const parsed = ContactSchema.safeParse(raw);
if (!parsed.success) \{
return \{
errors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
success: false,
\};
\}
try \{
await db.contact.create(\{ data: parsed.data \});
return \{ success: true, message: "Message sent!" \};
\} catch \{
return \{ success: false, message: "Failed to send. Try again." \};
\}
\}// ContactForm.tsx
"use client";
import \{ useActionState \} from "react";
import \{ useFormStatus \} from "react-dom";
import \{ submitContact \} from "./actions";
function SubmitButton() \{
const \{ pending \} = useFormStatus();
return (
<button type="submit" disabled=\{pending\}>
\{pending ? "Sending..." : "Send Message"\}
</button>
);
\}
export function ContactForm() \{
const [state, formAction] = useActionState(submitContact, \{
success: false,
\});
return (
<form action=\{formAction\}>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" required />
\{state.errors?.name && (
<p className="text-red-500">\{state.errors.name[0]\}</p>
)\}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
\{state.errors?.email && (
<p className="text-red-500">\{state.errors.email[0]\}</p>
)\}
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
\{state.errors?.message && (
<p className="text-red-500">\{state.errors.message[0]\}</p>
)\}
</div>
\{state.message && (
<p className=\{state.success ? "text-green-500" : "text-red-500"\}>
\{state.message\}
</p>
)\}
<SubmitButton />
</form>
);
\}"use client";
import \{ useForm \} from "react-hook-form";
import \{ zodResolver \} from "@hookform/resolvers/zod";
import \{ z \} from "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";
const schema = z.object(\{
username: z.string().min(3).max(20),
email: z.string().email(),
\});
export function ProfileForm() \{
const form = useForm<z.infer<typeof schema>>(\{
resolver: zodResolver(schema),
defaultValues: \{ username: "", email: "" \},
\});
return (
<Form \{...form\}>
<form onSubmit=\{form.handleSubmit(onSubmit)\} className="space-y-4">
<FormField
control=\{form.control\}
name="username"
render=\{(\{ field \}) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="johndoe" \{...field\} />
</FormControl>
<FormDescription>Your public display name.</FormDescription>
<FormMessage />
</FormItem>
)\}
/>
<Button type="submit">Save</Button>
</form>
</Form>
);
\}"use client";
import \{ useState \} from "react";
import \{ useForm \} from "react-hook-form";
import \{ zodResolver \} from "@hookform/resolvers/zod";
import \{ z \} from "zod";
const Step1Schema = z.object(\{
name: z.string().min(2),
email: z.string().email(),
\});
const Step2Schema = z.object(\{
address: z.string().min(5),
city: z.string().min(2),
zip: z.string().regex(/^\d\{5\}$/),
\});
const Step3Schema = z.object(\{
cardNumber: z.string().regex(/^\d\{16\}$/),
\});
const FullSchema = Step1Schema.merge(Step2Schema).merge(Step3Schema);
type FullFormData = z.infer<typeof FullSchema>;
const schemas = [Step1Schema, Step2Schema, Step3Schema] as const;
export function MultiStepForm() \{
const [step, setStep] = useState(0);
const form = useForm<FullFormData>(\{
resolver: zodResolver(schemas[step]),
mode: "onTouched",
defaultValues: \{
name: "", email: "", address: "", city: "", zip: "", cardNumber: "",
\},
\});
const onNext = async () => \{
const valid = await form.trigger();
if (valid) setStep((s) => s + 1);
\};
const onBack = () => setStep((s) => s - 1);
const onSubmit = async (data: FullFormData) => \{
// Final validation with full schema
const result = FullSchema.safeParse(data);
if (result.success) await submitOrder(result.data);
\};
return (
<form onSubmit=\{form.handleSubmit(onSubmit)\}>
\{step === 0 && <Step1Fields form=\{form\} />\}
\{step === 1 && <Step2Fields form=\{form\} />\}
\{step === 2 && <Step3Fields form=\{form\} />\}
<div className="flex gap-2">
\{step > 0 && <button type="button" onClick=\{onBack\}>Back</button>\}
\{step < 2 ? (
<button type="button" onClick=\{onNext\}>Next</button>
) : (
<button type="submit">Place Order</button>
)\}
</div>
</form>
);
\}mode: "onTouched" in React Hook Form
## Working Example
### Example 1: User asks "Build a signup form with validation"
**User prompt:** "I need a signup form with name, email, password, and confirm password with Zod validation."
**Skill-guided response would produce:**
- A Zod schema with password confirmation refinement
- A React Hook Form component with zodResolver
- Inline error display with aria attributes
- TypeScript types inferred from the schema
- Both client and server validation
### Example 2: User asks "How do I handle file uploads in a form?"
**Skill-guided response would include:**
- Zod schema with File validation (size, type)
- React Hook Form with `Controller` for the file input
- Server Action that processes FormData with the file
- Progress indication pattern
## Deep Dive
### How the Skill Works
This skill provides:
1. **Decision tree** - Chooses the right form pattern for the scenario
2. **Schema patterns** - Zod recipes for common validation needs
3. **Integration patterns** - React Hook Form + Zod, Server Actions, shadcn/ui
4. **Error handling rules** - Accessibility and UX best practices
5. **Advanced patterns** - Multi-step, file upload, optimistic forms
### Customization
- Add your project's form component library patterns
- Include custom validation rules specific to your domain
- Specify error display conventions
- Add API integration patterns for your backend
### How to Install
```bash
mkdir -p .claude/skills/forms-validation
# Paste the Recipe content into .claude/skills/forms-validation/SKILL.md
<form>, not the form in the same component.formData.get() always returns strings.Controller or register with manual handling.| Approach | When to Use |
|---|---|
| Formik | Legacy projects already using Formik |
| Conform | Server-first form validation (pairs well with Remix) |
| Native HTML validation | Simple forms with basic requirements |
| Final Form | Lightweight alternative to React Hook Form |
| Valibot | Smaller bundle alternative to Zod |
useActionState for state managementDefine the schema in a shared file and import it in both places:
// schemas/contact.ts
import { z } from "zod";
export const ContactSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
});
export type Contact = z.infer<typeof ContactSchema>;zodResolversafeParseformData.get() always returns strings, even for number inputsz.coerce.number() converts the string to a number before validatinguseFormStatus reads the pending status from the nearest parent <form> element<form>, there is no parent form yetSubmitButton component that lives inside the formtype FormState = {
errors?: Record<string, string[]>;
message?: string;
success: boolean;
};
export async function submitContact(
prevState: FormState,
formData: FormData
): Promise<FormState> {
// ...
}FormDatauseActionStatez.discriminatedUnion("method", [...]) switches validation rules based on a discriminator fielddefaultValues that match the full shape of your Zod schemaconst UploadSchema = z.object({
avatar: z
.instanceof(File)
.refine((f) => f.size <= 5 * 1024 * 1024, "Max 5MB")
.refine(
(f) => ["image/jpeg", "image/png"].includes(f.type),
"Only JPEG and PNG accepted"
),
});z.instanceof(File) as the base type.refine() calls for size and MIME type checksStep1Schema, Step2Schema, etc.)zodResolver in React Hook Formform.trigger() to validate the current step before advancingFullSchemaimport { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({ name: z.string().min(2) });
type FormData = z.infer<typeof schema>;
const form = useForm<FormData>({
resolver: zodResolver(schema),
});z.infer<typeof schema>useForm<FormData>z.coerce.number() coerces the input before any validation runsz.string().transform(Number) validates as string first, then transformsz.coerce is simpler and more directaria-invalid and role="alert" for accessibilitymode: "onTouched")Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥