Use Cases (Updated: 6/2/2026)

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.

Claude Code Form Validation with React Hook Form and Zod

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 caseValidation that mattersCommon trap
SaaS trial signupWork email, team size, plan, terms acceptanceNot deciding whether personal email domains are allowed
Contact formName, email, category, message length, spam-like URL countValidating only in the browser and trusting the API body
Admin user editRole, ownership, immutable IDs, allowed fieldsHiding fields in the UI but accepting them in the API
Booking or pre-payment formDate, quantity, phone, address, inventoryCreating 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.

#Claude Code #validation #forms #Zod #React
Free

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.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.