Claude Code Form Validation with React Hook Form and Zod
Build production-ready form validation with Claude Code, React Hook Form, Zod, server checks, i18n, a11y, and tests.
Start With the Contract, Not the Markup
Form validation is often treated as a small UI detail. In real products, it sits on the edge of revenue and trust: trial signup, contact requests, checkout, booking, onboarding, profile editing, and admin screens. A form that looks fine but loses server errors, allows duplicate submits, or sends the wrong data type is not production-ready.
Claude Code is useful here because validation work is structured. You can ask it to inspect the existing project, create a Zod schema, wire React Hook Form, normalize API errors, add accessible error markup, and write tests. But the prompt must describe the boundary. React Hook Form manages form state and submission. Zod is the schema, meaning the data contract. The resolver is the adapter between the form library and the schema. Server-side validation means checking the same rules in the API, because browser code can be bypassed. Error normalization means turning different failures into one shape the UI can render. i18n means internationalization, and accessibility means the error state is perceivable by keyboard and assistive technology users.
This guide uses a contact form to show a practical setup with React Hook Form, Zod, server validation, API error normalization, accessibility, i18n, tests, and common failure modes such as duplicate submits, type drift, and missing error display. For related internal reading, see React Hook Form with Claude Code and Zod validation with Claude Code. Check current behavior against the official Claude Code overview, React Hook Form useForm docs, React Hook Form Resolvers, Zod docs, Next.js Route Handlers, and Testing Library.
flowchart TD
A["User input"] --> B["React Hook Form"]
B --> C["zodResolver"]
C --> D{"Client valid?"}
D -->|No| E["Accessible field errors"]
D -->|Yes| F["POST /api/contact"]
F --> G["Server Zod validation"]
G --> H["Normalize API errors"]
H --> I["setError or root message"]
G --> J["Persist or notify"]
Use Cases That Change the Rules
Do not ask Claude Code to “add validation” without naming the business case. Different forms need different rules.
| Use case | Validation that matters | Common trap |
|---|---|---|
| SaaS trial signup | Work email, team size, plan, terms acceptance | Not deciding whether personal email domains are allowed |
| Contact form | Name, email, category, message length, spam-like URL count | Validating only in the browser and trusting the API body |
| Admin user edit | Role, ownership, immutable IDs, allowed fields | Hiding fields in the UI but accepting them in the API |
| Booking or pre-payment form | Date, quantity, phone, address, inventory | Creating duplicate bookings on double submit |
A good Claude Code prompt is explicit:
Implement a contact form.
Only change the files in this feature.
Use React Hook Form, Zod, and @hookform/resolvers/zod.
Validate on the client and again in the API using the same schema.
Normalize API failures as { ok: false, errors: [{ path, message }] }.
Include duplicate-submit prevention, aria-invalid, aria-describedby, role="alert",
i18n message keys, and Vitest/Testing Library tests.
Use copy-pasteable TypeScript and React, not pseudocode.
This prompt tells Claude Code to implement the unhappy path, not just the happy path. During review, check values, server boundaries, error rendering, duplicate submit behavior, accessibility, and tests.
Copy-Paste Zod Schema
The schema below models a B2B contact form. It stores message keys rather than final English sentences. That makes server responses reusable across English, Japanese, or any other locale.
// src/features/contact/contactSchema.ts
import { z } from "zod";
export const contactSchema = z
.object({
name: z
.string()
.trim()
.min(1, "validation.name.required")
.max(60, "validation.name.tooLong"),
email: z
.string()
.trim()
.min(1, "validation.email.required")
.email("validation.email.invalid"),
plan: z.enum(["starter", "team", "enterprise"], {
message: "validation.plan.invalid",
}),
seats: z
.number({ message: "validation.seats.number" })
.int("validation.seats.integer")
.min(1, "validation.seats.min")
.max(200, "validation.seats.max"),
message: z
.string()
.trim()
.min(20, "validation.message.tooShort")
.max(1000, "validation.message.tooLong"),
locale: z.enum(["ja", "en"], {
message: "validation.locale.invalid",
}),
agreeToTerms: z
.boolean()
.refine((value) => value === true, "validation.terms.required"),
})
.strict();
export type ContactFormData = z.infer<typeof contactSchema>;
export const defaultContactValues: ContactFormData = {
name: "",
email: "",
plan: "starter",
seats: 1,
message: "",
locale: "en",
agreeToTerms: false,
};
The seats field is intentionally a number. Browser number inputs can still reach your code as strings if you wire them incorrectly. Use React Hook Form’s valueAsNumber on the client, or use a separate API schema with z.coerce.number() when accepting raw form data. The important point is to decide deliberately instead of letting type drift slip through.
Server-Side Validation and Normalized API Errors
Client validation improves the user experience. It does not protect the system. The API must parse unknown JSON and run the schema again.
// src/app/api/contact/route.ts
import { z } from "zod";
import { contactSchema, type ContactFormData } from "@/features/contact/contactSchema";
type FieldPath = keyof ContactFormData | "root";
export type ApiFieldError = {
path: FieldPath;
message: string;
};
export type ContactApiResponse =
| { ok: true; id: string }
| { ok: false; errors: ApiFieldError[] };
function normalizeZodError(error: z.ZodError): ApiFieldError[] {
return error.issues.map((issue) => {
const firstPath = issue.path[0];
return {
path: typeof firstPath === "string" ? (firstPath as FieldPath) : "root",
message: issue.message,
};
});
}
function jsonResponse(body: ContactApiResponse, status: number): Response {
return Response.json(body, { status });
}
async function isBlockedDomain(email: string): Promise<boolean> {
return email.toLowerCase().endsWith("@example.invalid");
}
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return jsonResponse(
{ ok: false, errors: [{ path: "root", message: "validation.json.invalid" }] },
400,
);
}
const parsed = contactSchema.safeParse(body);
if (!parsed.success) {
return jsonResponse({ ok: false, errors: normalizeZodError(parsed.error) }, 422);
}
if (await isBlockedDomain(parsed.data.email)) {
return jsonResponse(
{ ok: false, errors: [{ path: "email", message: "validation.email.blocked" }] },
409,
);
}
// Replace this with database insert, CRM sync, or email notification.
const id = crypto.randomUUID();
return jsonResponse({ ok: true, id }, 201);
}
This API returns one error format for invalid JSON, Zod field failures, and business rules such as blocked email domains. That is the main reason to normalize errors. The UI does not need to know whether the failure came from Zod or a domain policy.
Accessible React Hook Form Component
This component prevents duplicate submits with isSubmitting, maps server field errors back into React Hook Form, and connects inputs to error messages with aria-describedby.
// src/features/contact/ContactForm.tsx
"use client";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import {
contactSchema,
defaultContactValues,
type ContactFormData,
} from "./contactSchema";
import type { ApiFieldError, ContactApiResponse } from "@/app/api/contact/route";
const messages = {
ja: {
"validation.name.required": "お名前を入力してください。",
"validation.name.tooLong": "お名前は60文字以内で入力してください。",
"validation.email.required": "メールアドレスを入力してください。",
"validation.email.invalid": "有効なメールアドレスを入力してください。",
"validation.email.blocked": "このメールドメインでは申し込めません。",
"validation.plan.invalid": "プランを選択してください。",
"validation.seats.number": "利用人数は数値で入力してください。",
"validation.seats.integer": "利用人数は整数で入力してください。",
"validation.seats.min": "利用人数は1人以上にしてください。",
"validation.seats.max": "利用人数は200人以下にしてください。",
"validation.message.tooShort": "相談内容は20文字以上で入力してください。",
"validation.message.tooLong": "相談内容は1000文字以内で入力してください。",
"validation.locale.invalid": "言語設定が不正です。",
"validation.terms.required": "利用規約への同意が必要です。",
"validation.json.invalid": "送信内容を読み取れませんでした。",
"form.submitError": "送信に失敗しました。時間をおいて再度お試しください。",
},
en: {
"validation.name.required": "Enter your name.",
"validation.name.tooLong": "Name must be 60 characters or fewer.",
"validation.email.required": "Enter your email address.",
"validation.email.invalid": "Enter a valid email address.",
"validation.email.blocked": "This email domain is not allowed.",
"validation.plan.invalid": "Choose a plan.",
"validation.seats.number": "Seats must be a number.",
"validation.seats.integer": "Seats must be an integer.",
"validation.seats.min": "Seats must be at least 1.",
"validation.seats.max": "Seats must be 200 or fewer.",
"validation.message.tooShort": "Message must be at least 20 characters.",
"validation.message.tooLong": "Message must be 1000 characters or fewer.",
"validation.locale.invalid": "Locale is invalid.",
"validation.terms.required": "You must agree to the terms.",
"validation.json.invalid": "The submitted body could not be read.",
"form.submitError": "Submit failed. Please try again later.",
},
} as const;
type Locale = keyof typeof messages;
const formFields = ["name", "email", "plan", "seats", "message", "locale", "agreeToTerms"] as const;
type FormField = (typeof formFields)[number];
function t(locale: Locale, key: string): string {
const table = messages[locale] as Record<string, string>;
return table[key] ?? key;
}
function isFormField(path: ApiFieldError["path"]): path is FormField {
return formFields.includes(path as FormField);
}
export function ContactForm({ locale = "en" }: { locale?: Locale }) {
const [serverMessage, setServerMessage] = useState<string | null>(null);
const {
register,
handleSubmit,
setError,
reset,
formState: { errors, isSubmitting },
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
defaultValues: { ...defaultContactValues, locale },
mode: "onBlur",
});
async function onValidSubmit(values: ContactFormData) {
setServerMessage(null);
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
const result = (await response.json()) as ContactApiResponse;
if (!response.ok || !result.ok) {
const apiErrors = result.ok ? [] : result.errors;
for (const error of apiErrors) {
if (isFormField(error.path)) {
setError(error.path, { type: "server", message: t(locale, error.message) });
} else {
setServerMessage(t(locale, error.message));
}
}
if (apiErrors.length === 0) setServerMessage(t(locale, "form.submitError"));
return;
}
reset({ ...defaultContactValues, locale });
}
return (
<form onSubmit={handleSubmit(onValidSubmit)} noValidate>
{serverMessage ? (
<p role="alert" aria-live="assertive">
{serverMessage}
</p>
) : null}
<div>
<label htmlFor="contact-name">Name</label>
<input
id="contact-name"
autoComplete="name"
aria-invalid={Boolean(errors.name)}
aria-describedby={errors.name ? "contact-name-error" : undefined}
{...register("name")}
/>
{errors.name?.message ? (
<p id="contact-name-error" role="alert">
{t(locale, errors.name.message)}
</p>
) : null}
</div>
<div>
<label htmlFor="contact-email">Email address</label>
<input
id="contact-email"
type="email"
autoComplete="email"
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? "contact-email-error" : undefined}
{...register("email")}
/>
{errors.email?.message ? (
<p id="contact-email-error" role="alert">
{t(locale, errors.email.message)}
</p>
) : null}
</div>
<div>
<label htmlFor="contact-plan">Plan</label>
<select id="contact-plan" {...register("plan")}>
<option value="starter">Starter</option>
<option value="team">Team</option>
<option value="enterprise">Enterprise</option>
</select>
</div>
<div>
<label htmlFor="contact-seats">Seats</label>
<input
id="contact-seats"
type="number"
min={1}
max={200}
aria-invalid={Boolean(errors.seats)}
aria-describedby={errors.seats ? "contact-seats-error" : undefined}
{...register("seats", { valueAsNumber: true })}
/>
{errors.seats?.message ? (
<p id="contact-seats-error" role="alert">
{t(locale, errors.seats.message)}
</p>
) : null}
</div>
<div>
<label htmlFor="contact-message">Message</label>
<textarea
id="contact-message"
rows={6}
aria-invalid={Boolean(errors.message)}
aria-describedby={errors.message ? "contact-message-error" : undefined}
{...register("message")}
/>
{errors.message?.message ? (
<p id="contact-message-error" role="alert">
{t(locale, errors.message.message)}
</p>
) : null}
</div>
<label>
<input type="checkbox" {...register("agreeToTerms")} />
I agree to the terms
</label>
{errors.agreeToTerms?.message ? (
<p role="alert">{t(locale, errors.agreeToTerms.message)}</p>
) : null}
<button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit"}
</button>
</form>
);
}
Style the component however your design system requires. Keep the validation structure intact: each field has a label, invalid state, optional error description, and server errors can return to fields.
Tests for Type Drift and Missing Errors
Focus tests on boundaries that commonly regress: schema acceptance, number type drift, client-side validation, API error mapping, and duplicate submit behavior.
// src/features/contact/ContactForm.test.tsx
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ContactForm } from "./ContactForm";
import { contactSchema } from "./contactSchema";
const validInput = {
name: "Masa",
email: "masa@example.com",
plan: "team",
seats: 3,
message: "I want to improve validation in a Claude Code workflow.",
locale: "en",
agreeToTerms: true,
} as const;
describe("contactSchema", () => {
it("accepts valid input", () => {
expect(contactSchema.safeParse(validInput).success).toBe(true);
});
it("rejects string seats", () => {
const result = contactSchema.safeParse({ ...validInput, seats: "3" });
expect(result.success).toBe(false);
});
});
describe("ContactForm", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("shows client validation errors and does not submit", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch");
render(<ContactForm locale="en" />);
await userEvent.click(screen.getByRole("button", { name: "Submit" }));
expect(await screen.findByText("Enter your name.")).toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalled();
});
it("maps API field errors back to the email field", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(
JSON.stringify({
ok: false,
errors: [{ path: "email", message: "validation.email.blocked" }],
}),
{ status: 409, headers: { "Content-Type": "application/json" } },
),
);
render(<ContactForm locale="en" />);
await userEvent.type(screen.getByLabelText("Name"), "Masa");
await userEvent.type(screen.getByLabelText("Email address"), "masa@example.invalid");
await userEvent.selectOptions(screen.getByLabelText("Plan"), "team");
await userEvent.clear(screen.getByLabelText("Seats"));
await userEvent.type(screen.getByLabelText("Seats"), "3");
await userEvent.type(screen.getByLabelText("Message"), "I need help with form validation.");
await userEvent.click(screen.getByRole("checkbox"));
await userEvent.click(screen.getByRole("button", { name: "Submit" }));
expect(await screen.findByText("This email domain is not allowed.")).toBeInTheDocument();
});
});
These tests catch the failures users actually feel: the form submits too early, a number becomes a string, or a server-side rule disappears instead of being shown.
Failure Patterns to Ask Claude Code to Review
First, client-only validation. If the API does not run safeParse, the system still trusts untrusted input.
Second, duplicate submit. isSubmitting helps, but booking and payment flows may also need idempotency keys or database uniqueness.
Third, type drift. Number inputs, checkboxes, dates, and empty select values are frequent sources of bugs.
Fourth, missing error display. Zod errors may render while business errors or root errors vanish. Message keys may also leak into the UI if the dictionary is incomplete.
Fifth, accessibility as an afterthought. Red text alone is not enough. Review labels, aria-invalid, aria-describedby, alerts, keyboard behavior, and focus.
Sixth, broad Claude Code edits. A validation task should not silently rewrite authentication, persistence, or unrelated styling. Put file scope and test commands in the prompt.
Monetization and Production Value
Forms are conversion infrastructure. A broken trial form wastes ad spend and SEO traffic. A confusing inquiry form increases support work. A translated site that only breaks validation messages in one locale makes the product feel unreliable. ClaudeCodeLab’s Claude Code training and consulting can help teams turn this into a repeatable workflow: prompt design, validation architecture, test coverage, and review checklists for the real codebase.
This is especially valuable for SaaS onboarding, B2B lead capture, paid content checkout, booking flows, and internal admin tools where bad input turns into manual cleanup.
What Happened When We Tried It
In Masa’s test implementation, the first Claude Code pass produced a good-looking form but missed two practical issues: seats reached the API as a string, and the blocked-domain API error was not shown in the email field. Adding valueAsNumber, the normalized API error shape, and Testing Library regression tests fixed both. Returning message keys instead of final sentences also made the Japanese and English UI easier to verify because the same API response worked for both locales.
Summary
Use Claude Code for form validation as a full boundary task: React Hook Form, Zod, server validation, normalized API errors, accessibility, i18n, tests, and review prompts. The goal is not only to generate a form faster. The goal is to make invalid input, failed requests, translated errors, and repeated clicks behave predictably in production.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.