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

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.

~/.claude/skills/forms-validation/SKILL.md

--- name: forms-validation description: Builds accessible, progressively enhanced web forms with one Zod schema shared by client and server, React Hook Form or native FormData,… license: MIT ---

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:

FieldInputautocompleteRulesError copy
Emailtype="email"emailrequired, valid email“Enter an email address like [email protected]”
Passwordtype="password"new-passwordmin 12 chars“Use at least 12 characters”
Postcodetype="text" inputmode="numeric"postal-codecountry-specific“Enter a 5-digit ZIP code”
Quantitytype="number" min="1"offint 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; use z.coerce.number() / z.coerce.date() or z.stringbool() for booleans.
  • Unchecked checkboxes are absent, not "false".
  • Empty optional inputs arrive as "", not undefined: z.string().trim().optional().or(z.literal("")) or preprocess "" to undefined.
  • Multiple values (<select multiple>, checkbox groups) need formData.getAll(name).
  • On Zod 3, use .email() on strings and message instead of error; z.flattenError(err) (v4) replaces err.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 with aria-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, and autocomplete tokens; 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.
  • noValidate turns off browser bubbles so your accessible messages are the only ones; keep required, type, min, max for semantics and mobile keyboards.

Validation timing

MomentBehavior
While typing (pristine field)No errors
On blurValidate that field
After a field has shown an errorRe-validate on each change so the error clears as soon as it is fixed
On submitValidate 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 register for native inputs; Controller only for custom components that do not expose a ref.
  • Provide defaultValues for every field; undefined defaults cause controlled/uncontrolled warnings and broken reset().
  • Read formState.errors and isSubmitting inside 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

  1. Disable JavaScript; submit invalid then valid data. Errors render with values kept; success redirects.
  2. Keyboard only: tab order matches visual order, every control reachable, submit with Enter.
  3. Screen reader (VoiceOver or NVDA): each field announces label, required state, hint, and error; focus lands on the summary or first error after submit.
  4. Force a server error (duplicate email) and confirm it appears on the right field.
  5. Double-click submit: one request.
  6. 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.

esc