Formularvalidierung mit Claude Code, React Hook Form und Zod
Baue Formularvalidierung mit Claude Code, React Hook Form, Zod, Serverprüfung, API-Fehlern, i18n, A11y und Tests.
Erst den Vertrag definieren, dann das Formular bauen
Formularvalidierung ist mehr als ein roter Hinweis bei leeren Feldern. In echten Produkten hängt daran oft Umsatz: Testzugänge, Kontaktanfragen, Buchungen, Checkout, Onboarding oder interne Admin-Masken. Ein Formular kann ordentlich aussehen und trotzdem fehlerhaft sein, wenn es doppelte Submits zulässt, Zahlen als Strings sendet, Serverfehler verschluckt oder Fehlermeldungen für Screenreader nicht zugänglich macht.
Claude Code ist für diese Arbeit nützlich, weil die Aufgabe gut strukturierbar ist. React Hook Form verwaltet Formularzustand und Submit. Zod beschreibt das Schema, also den Datenvertrag. Der Resolver verbindet React Hook Form mit Zod. Servervalidierung bedeutet, dass die API den JSON-Body erneut prüft, weil Browsercode umgangen werden kann. Fehlernormalisierung bedeutet, Zod-Fehler, Business-Regeln und JSON-Parsing-Fehler in dasselbe Format zu bringen. i18n steht für Internationalisierung; Accessibility bedeutet, dass Fehler auch per Tastatur und assistiver Technologie nachvollziehbar sind.
Dieser Artikel zeigt an einem B2B-Kontaktformular, wie Claude Code React Hook Form, Zod, Servervalidierung, API-Fehlernormalisierung, Accessibility, i18n und Tests umsetzt. Als interne Ergänzung passen React Hook Form mit Claude Code und Zod-Validierung mit Claude Code. Prüfe Details in den offiziellen Quellen: Claude Code overview, React Hook Form useForm, React Hook Form Resolvers, Zod, Next.js Route Handlers und 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 mit unterschiedlichen Regeln
Eine vage Bitte wie “Validierung hinzufügen” reicht nicht. Der Formulartyp entscheidet über die Regeln.
| Use Case | Wichtige Prüfung | Typischer Fehler |
|---|---|---|
| SaaS-Testzugang | Firmen-E-Mail, Teamgröße, Plan, AGB-Zustimmung | Nicht klären, ob private E-Mail-Domains erlaubt sind |
| Kontaktformular | Name, E-Mail, Kategorie, Nachrichtenlänge, verdächtige URLs | Nur im Browser validieren und die API vertrauen lassen |
| Admin-Benutzerbearbeitung | Rolle, Berechtigung, unveränderliche ID, erlaubte Felder | Felder nur im UI verstecken, aber in der API annehmen |
| Buchung oder Pre-Payment | Datum, Anzahl, Telefon, Adresse, Verfügbarkeit | Doppelte Buchungen durch Doppelklick erzeugen |
Ein guter Prompt für Claude Code ist konkret:
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.
Damit wird nicht nur die Erfolgsspur gebaut. Claude Code soll auch Fehlerszenarien, Servergrenzen, Barrierefreiheit und Tests berücksichtigen.
Kopierbares Zod-Schema
Das Schema nutzt Message Keys statt finaler deutscher Texte. Die API kann dieselbe Fehlermeldung liefern, und die Oberfläche übersetzt sie passend zur Sprache.
// 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,
};
seats ist absichtlich eine Zahl. Ein HTML-Number-Input kann bei falscher Verdrahtung trotzdem als String in der Anwendung landen. Nutze in React Hook Form valueAsNumber oder für rohe FormData ein separates API-Schema mit z.coerce.number().
Servervalidierung und normalisierte API-Fehler
Clientvalidierung hilft Nutzern, schützt aber nicht das System. In der API bleibt der Body unknown, bis Zod ihn akzeptiert.
// 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);
}
Die UI muss dadurch nur path und message lesen. Ob der Fehler aus Zod, JSON-Parsing oder einer Domain-Regel kommt, spielt dort keine Rolle.
Accessible React Hook Form Component
Die Komponente verhindert doppelte Submits mit isSubmitting, verbindet Fehlermeldungen per aria-describedby und schreibt API-Fehler zurück ins passende Feld.
// 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 = {
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}
<label htmlFor="contact-name">Name</label>
<input id="contact-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}
<label htmlFor="contact-email">Email address</label>
<input id="contact-email" type="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}
<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>
<label htmlFor="contact-seats">Seats</label>
<input id="contact-seats" type="number" min={1} max={200} {...register("seats", { valueAsNumber: true })} />
<label htmlFor="contact-message">Message</label>
<textarea id="contact-message" rows={6} {...register("message")} />
<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>
);
}
Für ein deutsches Produkt sollten Labels und Buttons ebenfalls übersetzt werden. Die Fehlerstruktur bleibt gleich.
Tests, Fehlerbilder und Ergebnis
// 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 { 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;
it("accepts valid schema input", () => {
expect(contactSchema.safeParse(validInput).success).toBe(true);
});
it("rejects string seats", () => {
expect(contactSchema.safeParse({ ...validInput, seats: "3" }).success).toBe(false);
});
it("does not submit invalid form", 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();
});
Typische Fehler sind Client-only-Validierung, fehlender Schutz gegen Doppelklicks, Typdrift bei Zahlen und Checkboxen, nicht angezeigte Serverfehler, rote Fehlermeldungen ohne Accessibility und zu große Änderungen durch Claude Code.
Formulare sind Conversion-Infrastruktur. Ein kaputter Testzugang verschwendet SEO- und Ad-Traffic. Ein unklarer Kontaktfehler kostet Leads. In Claude Code Training und Beratung kann ClaudeCodeLab Prompts, Schemas, Tests und Review-Checklisten am echten Repository schärfen.
Beim Test von Masa sah die erste Version gut aus, aber seats kam als String in der API an und der Fehler für eine blockierte Domain erschien nicht im E-Mail-Feld. valueAsNumber, API-Fehlernormalisierung und Testing-Library-Regressionstests haben beide Probleme reproduzierbar gemacht. Message Keys erleichterten außerdem die Prüfung einer japanischen und englischen Oberfläche mit derselben Serverantwort.
Fazit
Mit Claude Code sollte Formularvalidierung als komplette Grenze behandelt werden: React Hook Form, Zod, Server, API-Fehler, Accessibility, i18n und Tests. So entsteht nicht nur schneller Code, sondern ein Formular, das bei falscher Eingabe und Serverablehnung zuverlässig bleibt.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Workflow von Obsidian zu CLAUDE.md
Obsidian-Arbeitsnotizen in CLAUDE.md-Betriebsnotizen verwandeln und Kontext nicht ständig neu erklären.
Claude Code Revenue CTA Routing: Artikel zu PDF, Gumroad und Beratung führen
Ein Claude-Code-Ablauf, der Leser nach Absicht zu Gratis-PDF, Gumroad oder Beratung führt.
Claude-Code-Team-Handoff-Regeln: Belege, Berechtigungen, Rollback und Umsatzpfade
Ein praktisches Claude-Code-Handoff für Review-Belege, Berechtigungen, Rollback, Gratis-PDF, Gumroad und Beratung.