Implement field-level validation, server error hydration, typed Server Actions, and accessible shadcn/ui fields for…
·Updated on:··
⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
Last updated: 2026-05-13 · Tested with Next.js 16.2.3, @tanstack/react-form 1.29.1, Zod 4.3.6, shadcn/ui Field primitives
If you're building medium or complex forms in a Next.js App Router project, you need field-level validation, server error hydration under specific inputs, typed action results, and a consistent submit state. This guide explains the exact architecture I use: TanStack Form for client state, Zod for shared validation between client and server, shadcn/ui Field primitives for accessible UI, and Next.js Server Actions for trusted mutations.
I landed on this pattern while building the office login flow for a client project. The native <form action={serverAction}> approach handled simple cases well, but as soon as I needed field-level error display from a failed login attempt, it became clear I needed something with more explicit state. TanStack Form solved that cleanly.
The canonical implementation lives in . Every new form in this project follows the same shape.
Use this architecture when the form needs at least one of the following:
Requirement
Use this pattern
Field-level client validation
Yes
Server errors displayed under specific inputs
Yes
Loading or disabled submit state
Yes
Typed action results with redirect
Yes
Shared schema on client and server
Yes
Interactive or conditional fields
Yes
For simple forms — a search input, a single-field email capture — a native <form action={serverAction}> is enough. Reach for this pattern when the UX needs to be more precise.
Important: this pattern requires client JavaScript. It is optimized for rich field-level UX, not native progressive enhancement. If the form must work without JavaScript, use <form action={serverAction}> and parse FormData in the Server Action instead.
The mental model
Think in four layers:
Schema layer (zod): defines what valid input looks like
Client form layer (@tanstack/react-form): tracks current field state
UI layer (shadcn/ui): renders fields, labels, and error messages
Server mutation layer (Server Action): performs the actual backend operation
Each layer has a single responsibility. Zod runs on both the client (UX feedback) and the server (trust boundary). The Server Action is the only place that can be trusted — client validation is always just convenience.
Other form approaches in Next.js App Router
Before jumping into the implementation, here is how the common approaches compare:
z.input<typeof schema> is the type the Server Action accepts from the client. z.output<typeof schema> is only available after safeParse() succeeds on the server — it reflects transforms like .trim(). TanStack form state will still hold the untransformed input value, so always use parsed.data on the server side for the clean, normalized payload.
The two exported result types — FeatureFormFieldErrors and FeatureFormActionResult — are what lets you hydrate specific field errors back into the form from the server response.
A few things worth calling out here. The server re-validates with safeParse even though the client already validated — this is deliberate. Never trust only client-side input. The redirect is validated against an explicit allowlist, not taken directly from the client input. Login errors use a generic message that doesn't reveal whether the email exists.
z.flattenError() works well for flat schemas like this one. If you're working with nested objects, arrays, or repeatable field groups, reach for z.treeifyError() or write a custom mapper instead.
A few things worth understanding here. Server errors for a field are cleared as soon as the user edits that input again — that keeps the UX feeling responsive instead of stale. aria-invalid is set explicitly so screen readers and accessibility tooling know the field state. The submit button reads from canSubmit and isSubmitting rather than a local state variable, so TanStack's internal form state is always the source of truth.
Note on isPristine: the original pattern included isPristine in the button disabled logic. It is removed here because password managers and browser autofill may populate fields without triggering normal change events, which can leave isPristine as true even when the fields visually appear filled. For most forms outside of login flows, you can add it back: disabled={!canSubmit || isSubmitting || isPristine}.
Handling cross-field validation
If a validation rule spans multiple fields — for example "phone or email is required" — assign the error to both field paths in Zod using superRefine():
Both fields will highlight independently, and the error message appears under each one. A top-level banner alone is not enough — the user needs to see which field is invalid.
Checklist for new forms
When creating a new form, work through this in order:
Create *.validation.ts with the Zod schema, input type, result type, and field error type
Create a typed Server Action that calls safeParse, handles auth/authz, and returns a FormActionResult
Build the client form with useForm, map fieldErrors from the result to per-field state
Clear server errors on field change
Use aria-invalid and data-invalid on every Field
Derive submit button state from canSubmit and isSubmitting
Run npx tsc --noEmit and manually test success and failure paths
Common mistakes
These come up repeatedly when adopting this pattern for the first time. Client-only validation is the biggest one — the Server Action must always re-validate with safeParse regardless of what the client checked. Forgetting event.preventDefault() in the form's onSubmit handler causes a full page reload. Not clearing server field errors on input change leaves stale error messages visible after the user has already corrected the field. Returning an unstructured error string from the Server Action instead of a typed fieldErrors object makes per-field display impossible.
FAQ
Should I use TanStack Form or native Server Actions?
For simple forms — single fields, minimal interaction — use <form action={serverAction}>. Reach for TanStack Form when you need field-level error display, explicit submit state, or server error hydration under specific inputs.
Can I use this pattern with progressive enhancement?
No. This pattern calls the Server Action from client-managed onSubmit, which requires JavaScript. For progressive enhancement, use <form action={serverAction}> and parse FormData directly in the action.
How do I show Server Action errors under specific fields?
Return a fieldErrors object from the action with field names as keys and string arrays as values. On the client, store that in a serverFieldErrors state object and pass each field's errors to FieldError alongside client-side errors.
Should validation run on client, server, or both?
Both, always. Client validation improves UX with immediate feedback. Server validation is the trust boundary — it must run regardless of what the client already checked.
How do I handle redirects safely?
Validate the redirect path against an explicit allowlist in the Server Action. Never pass raw client input directly to router.push or redirect() without checking it.
What's the difference between z.input and z.output?
z.input<typeof schema> is the type before transforms run — what the client sends to the Server Action. z.output<typeof schema> is what you get after safeParse() succeeds, with transforms like .trim() applied. Always use parsed.data in the Server Action as the trusted, normalized payload.
Conclusion
This pattern gives you a predictable, repeatable architecture for any medium or complex form: shared Zod schema for both client feedback and server trust, TanStack Form for explicit field state, shadcn/ui Field primitives for accessible markup, and a typed Server Action for the actual mutation.
The canonical implementation to reference is src/components/office/OfficeLoginForm.tsx. Copy the structure, swap the schema and action, and the pattern carries forward cleanly.
Let me know in the comments if you run into specific edge cases or have questions about adapting this to more complex form layouts, and subscribe for more practical development guides.
Thanks,
Matija
I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.