Forms and Validation skill for Claude Code
One schema, server-checked, errors a screen reader hears. Claude starts each form from a field spec with input types, autocomplete tokens and actionable error copy, then writes one Zod schema used by both browser and server. Forms submit without JavaScript, keep values on error, map API failures such as a taken email onto the right field, and follow WCAG 2.2 rules on labels, focus after submit, paste and password managers.
- Name
forms-validation- Size
- 198 lines · ≈2,200 tokens
- Files
- 1
- License
- MIT
- Updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds accessible, progressively enhanced web forms with one Zod schema shared by client and server, React Hook Form or native FormData, server-side error mapping back to fields, and WCAG-compliant labels, error messages, and focus handling. Use when creating sign-up, checkout, settings, or multi-step forms, adding validation, showing API errors on the right field, fixing forms that break without JavaScript, or auditing form accessibility. Try asking
- Build a signup form with Zod validation that also works without JavaScript
- Show the API's 'email already taken' error under the email field
- Audit our checkout form for accessibility and validation timing problems
Use it when
- Building signup, checkout, settings or multi-step forms
- Client and server validation rules have drifted apart
- Server or API errors show as a generic toast instead of on fields
- A form fails accessibility review or breaks with JS disabled
Skip it when
- You need a full-page WCAG audit beyond forms — use accessibility-audit
- The task is the wording of error messages across the product — use ux-microcopy
How to check the result
Submitting with JavaScript disabled shows field errors with values preserved, keyboard and screen reader passes announce labels and errors, and a forced server error lands on the right field.
The common mistake it prevents
Validating only in the browser, so any direct POST or outdated client bypasses the rules the UI appears to enforce.
A good form works without JavaScript, validates on the server with the same schema the client uses, tells the user exactly which field is wrong and how to fix it in text a screen reader announces, keeps their input on error, and never blocks paste or password managers.
Workflow
- [ ] 1. List fields with type, constraints, autocomplete token, and error copy
- [ ] 2. Write one schema (Zod) in a module importable by client and server
- [ ] 3. Build semantic markup: label, input type, autocomplete, describedby
- [ ] 4. Server handler validates with the schema and returns field errors + values
- [ ] 5. Client enhancement: inline validation timing, pending state, focus management
- [ ] 6. Map server/API errors to fields and a form-level message
- [ ] 7. Test: no-JS submit, keyboard-only, screen reader, server error path
Field spec first
Write this table before code; it catches missing constraints and copy:
| Field | Input | autocomplete | Rules | Error copy |
|---|---|---|---|---|
type="email" | email | required, valid email | “Enter an email address like [email protected]” | |
| Password | type="password" | new-password | min 12 chars | “Use at least 12 characters” |
| Postcode | type="text" inputmode="numeric" | postal-code | country-specific | “Enter a 5-digit ZIP code” |
| Quantity | type="number" min="1" | off | int 1–99 | “Enter a quantity from 1 to 99” |
Error copy says what to do, not just what is wrong (“Enter…”, “Use…”). Never “Invalid input”.
One schema (Zod 4)
// lib/schemas/signup.ts — imported by both client and server
import { z } from "zod";
export const SignupSchema = z.object({
name: z.string().trim().min(1, { error: "Enter your name" }).max(100),
email: z.email({ error: "Enter an email address like [email protected]" }),
password: z.string().min(12, { error: "Use at least 12 characters" }),
plan: z.enum(["free", "pro"], { error: "Choose a plan" }),
terms: z.literal("on", { error: "Accept the terms to continue" }), // checkbox value from FormData
seats: z.coerce.number().int().min(1).max(500), // FormData values are strings
});
export type SignupInput = z.infer<typeof SignupSchema>;
FormData traps:
- Every value is a string or
File; usez.coerce.number()/z.coerce.date()orz.stringbool()for booleans. - Unchecked checkboxes are absent, not
"false". - Empty optional inputs arrive as
"", notundefined:z.string().trim().optional().or(z.literal(""))or preprocess""toundefined. - Multiple values (
<select multiple>, checkbox groups) needformData.getAll(name). - On Zod 3, use
.email()on strings andmessageinstead oferror;z.flattenError(err)(v4) replaceserr.flatten().
Server handler (framework-neutral shape)
export async function signup(_prev: FormState, formData: FormData): Promise<FormState> {
const raw = Object.fromEntries(formData);
const parsed = SignupSchema.safeParse(raw);
if (!parsed.success) {
return { ok: false, fieldErrors: z.flattenError(parsed.error).fieldErrors, values: omitSecrets(raw) };
}
const result = await createAccount(parsed.data);
if (result.code === "EMAIL_TAKEN") {
return { ok: false, fieldErrors: { email: ["An account with this email already exists. Sign in instead."] }, values: omitSecrets(raw) };
}
redirect("/welcome"); // or return { ok: true }
}
- Always validate on the server, even when the client validated.
- Return submitted values (minus passwords and card data) so the form re-renders filled.
- Business-rule errors (email taken, coupon expired) map to the specific field when one applies, otherwise to a form-level error.
- Use POST-redirect-GET (303) after success so refresh does not resubmit.
Accessible markup
<form action={formAction} noValidate aria-describedby={state.formError ? "form-error" : undefined}>
{state.formError && <p id="form-error" role="alert">{state.formError}</p>}
<div>
<label htmlFor="email">Email</label>
<input
id="email" name="email" type="email" autoComplete="email" required
defaultValue={state.values?.email}
aria-invalid={!!state.fieldErrors?.email}
aria-describedby={state.fieldErrors?.email ? "email-error" : "email-hint"}
/>
<p id="email-hint">We will send a confirmation link.</p>
{state.fieldErrors?.email && <p id="email-error">{state.fieldErrors.email[0]}</p>}
</div>
<SubmitButton />
</form>
Rules:
- Every input has a visible
<label>(placeholder is not a label). Group radios and checkboxes in<fieldset>with<legend>. - Errors are text, linked with
aria-describedby, flagged witharia-invalid; color is never the only signal. - Mark required fields in the label text (“Email (required)”) or explain the convention once at the top.
- On submit failure, move focus to an error summary listing links to each invalid field, or to the first invalid field. Do not announce every keystroke error.
- Use correct
type,inputmode, andautocompletetokens; they drive mobile keyboards and autofill. - Never disable paste, and never block password managers (WCAG 2.2 3.3.8 Accessible Authentication). Allow a show-password toggle.
- Do not ask for the same information twice in one flow; prefill or offer “same as billing” (WCAG 2.2 3.3.7 Redundant Entry).
- Keep the submit button enabled; disabling it hides why the form will not submit. Show a pending state and prevent double-submit instead.
noValidateturns off browser bubbles so your accessible messages are the only ones; keeprequired,type,min,maxfor semantics and mobile keyboards.
Validation timing
| Moment | Behavior |
|---|---|
| While typing (pristine field) | No errors |
| On blur | Validate that field |
| After a field has shown an error | Re-validate on each change so the error clears as soon as it is fixed |
| On submit | Validate all, show summary, focus first error |
| Async checks (username taken) | On blur, debounced 300–500 ms, with a visible “checking” state |
React Hook Form encodes this as mode: "onTouched" (or onBlur with
reValidateMode: "onChange").
React Hook Form with the shared schema
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const form = useForm({
resolver: zodResolver(SignupSchema),
mode: "onTouched",
defaultValues: { name: "", email: "", password: "", plan: "free", seats: 1 },
});
async function onSubmit(values: SignupInput) {
const res = await fetch("/api/signup", { method: "POST", body: JSON.stringify(values), headers: { "content-type": "application/json" } });
if (res.ok) return router.push("/welcome");
const problem = await res.json(); // e.g. RFC 9457 body with an errors map
for (const [field, messages] of Object.entries(problem.errors ?? {})) {
form.setError(field as keyof SignupInput, { type: "server", message: (messages as string[])[0] });
}
if (!problem.errors) form.setError("root.server", { message: problem.detail ?? "Something went wrong. Try again." });
form.setFocus(Object.keys(problem.errors ?? {})[0] as keyof SignupInput);
}
- Use
registerfor native inputs;Controlleronly for custom components that do not expose a ref. - Provide
defaultValuesfor every field; undefined defaults cause controlled/uncontrolled warnings and brokenreset(). - Read
formState.errorsandisSubmittinginside render (it is a proxy; destructure in render, not in callbacks). - Choose RHF for large client-heavy forms (dynamic field arrays, wizards). For simple forms in frameworks with Actions (React 19, SvelteKit, Remix), native forms plus server validation are less code and work without JS.
Multi-step forms
- Validate each step with a sub-schema (
SignupSchema.pick({...})) before advancing. - Keep progress in the URL (
?step=2) or server session so refresh does not lose data. - Show “Step 2 of 4” in text and move focus to the step heading on change.
- Submit the final payload against the full schema on the server.
Verification
- Disable JavaScript; submit invalid then valid data. Errors render with values kept; success redirects.
- Keyboard only: tab order matches visual order, every control reachable, submit with Enter.
- Screen reader (VoiceOver or NVDA): each field announces label, required state, hint, and error; focus lands on the summary or first error after submit.
- Force a server error (duplicate email) and confirm it appears on the right field.
- Double-click submit: one request.
- Unit-test the schema with valid, boundary, and invalid cases; it is shared code.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.