Skip to main content

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.

SymptomConsequence
TanStack onSubmit skippedCustom validation logic never executes.
ARIA attributes missingNo aria-invalid is set, breaking accessibility.
Error messages hiddenNo error messages render in the DOM.
Tests fail silentlyPlaywright/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 PatternWhy It Matters
Submit Invalid DataAsserts the form validation executes before the network request.
Wait for DOM TextValidates that user-facing error messages render (browser tooltips do not appear in DOM).
Assert aria-invalidConfirms 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.

StepCheckWhat Breaks
1. Action mechanismIs action a server action or a client function?Client functions are dead before hydration — toBeEditable proves DOM readiness, not React handler attachment
2. FormData contentDoes every input have a name attribute?Server actions read FormData — missing name means missing data
3. Server responseDoes the server action call redirect() or return data?No redirect = form submits successfully but page doesn't navigate
4. ValidationIs 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

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?