Next.js Forms
Why abstract forms? Because browser defaults sabotage validation.
// ❌ Dead before hydration + browser validation fights TanStack
<form action={() => form.handleSubmit()}>
// ✅ Server action runs always + TanStack enhances client-side
<FormShell action={serverAction} form={form}>
Engineer discipline into the system; don't rely on operator memory. The correct path must be the default path.
The Core Problem
Browser native validation (HMTL5) silently blocks TanStack Form validators from executing. For example, an <input type="email"> without noValidate on the wrapping <form> means the browser native tooltip fires first.
| Symptom | Consequence |
|---|---|
TanStack onSubmit skipped | Custom validation logic never executes. |
| ARIA attributes missing | No aria-invalid is set, breaking accessibility. |
| Error messages hidden | No error messages render in the DOM. |
| Tests fail silently | Playwright/E2E tests see a hanging form submit, as the native tooltip is invisible to them. |
The FormShell Organism
The solution is the FormShell organism. It wraps the native <form> element, taking over submission and automatically suppressing browser validation.
import { FormApi } from "@tanstack/react-form";
interface FormShellProps {
action: (formData: FormData) => Promise<void>; // server action
form: FormApi<any, any>;
children: React.ReactNode;
className?: string;
}
export function FormShell({ action, form, children, className, ...props }: FormShellProps) {
return (
<form action={action} noValidate className={className} {...props}>
{children}
</form>
);
}
Usage Pattern
Developers pass the TanStack form instance directly to the shell and get the correct behavior by default.
import { FormShell } from "@stackmates/ui-forms";
import { createContactAction } from "@stackmates/app-server/actions";
export function ContactForm() {
const form = useForm({ defaultValues: { email: "" } });
return (
<FormShell action={createContactAction} form={form}>
<Field name="email">{/* ... */}</Field>
</FormShell>
);
}
E2E Testing Strategy
Because FormShell guarantees that TanStack Form handles validation, it establishes a predictable pattern for Playwright to interact with error states.
| Testing Pattern | Why It Matters |
|---|---|
| Submit Invalid Data | Asserts the form validation executes before the network request. |
| Wait for DOM Text | Validates that user-facing error messages render (browser tooltips do not appear in DOM). |
Assert aria-invalid | Confirms the accessibility layer is providing feedback. |
// Playwright Validation Testing Pattern
test("validates email format", async ({ page }) => {
await page.getByLabel("Email").fill("invalid-email");
await page.getByRole("button", { name: "Submit" }).click();
// Assert DOM-visible error
await expect(page.getByText("Invalid email format")).toBeVisible();
// Assert accessibility state
await expect(page.getByLabel("Email")).toHaveAttribute("aria-invalid", "true");
});
Submit Diagnosis
When an E2E spec fills a form and clicks submit but nothing happens, walk this chain before touching the test.
| Step | Check | What Breaks |
|---|---|---|
| 1. Action mechanism | Is action a server action or a client function? | Client functions are dead before hydration — toBeEditable proves DOM readiness, not React handler attachment |
| 2. FormData content | Does every input have a name attribute? | Server actions read FormData — missing name means missing data |
| 3. Server response | Does the server action call redirect() or return data? | No redirect = form submits successfully but page doesn't navigate |
| 4. Validation | Is Zod rejecting silently? Auth check failing? | Server action returns early without error feedback |
The chain connects three pages: anti-patterns names the wrong pattern, this page names the right pattern, and the testing layer model determines where to test it. When E2E debugging exceeds 30 minutes, the test is at the wrong layer — decompose to L2 integration first.
Context
- Anti-Patterns — Prevent architecture violations like raw forms
- Component Driven Development — Form components structure
- Best Practices — Ensure forms respect Layer boundaries and types
- Testing — E2E validation against forms
- React Components — Composition of generic UI logic
Questions
What is the real cost of browser-native form validation fighting your library's validation?
- When does a custom form abstraction save more time than it costs to maintain?
- How do you test form error states without brittle selectors?
- When does client-side validation justify its bundle cost over server-only validation?