---
title: "Next.js Forms: Ultimate TanStack + Zod Pattern (shadcn/ui)"
slug: "nextjs-forms-tanstack-zod-shadcn-server-actions-pattern"
published: "2026-05-13"
updated: "2026-05-15"
categories:
  - "Next.js"
tags:
  - "Next.js forms"
  - "TanStack Form"
  - "Zod validation"
  - "Server Actions"
  - "shadcn/ui"
  - "field-level validation"
  - "server error hydration"
  - "typed Server Action"
  - "accessible form fields"
  - "Next.js form validation pattern"
llm-intent: "how-to"
audience-level: "intermediate"
llm-purpose: "Next.js forms: learn the TanStack Form + Zod + shadcn/ui architecture for field validation, server-error hydration and typed Server Actions. Get the…"
llm-prereqs:
  - "Next.js (App Router)"
  - "@tanstack/react-form (TanStack Form)"
  - "Zod"
  - "shadcn/ui"
  - "React"
  - "TypeScript"
  - "Next.js Server Actions"
  - "Payload CMS (login example)"
---

**Summary Triples**
- (Next.js Forms: Ultimate TanStack + Zod Pattern (shadcn/ui), expresses-intent, how-to)
- (Next.js Forms: Ultimate TanStack + Zod Pattern (shadcn/ui), covers-topic, Next.js forms)
- (Next.js Forms: Ultimate TanStack + Zod Pattern (shadcn/ui), provides-guidance-for, Next.js forms: learn the TanStack Form + Zod + shadcn/ui architecture for field validation, server-error hydration and typed Server Actions. Get the…)

### {GOAL}
Next.js forms: learn the TanStack Form + Zod + shadcn/ui architecture for field validation, server-error hydration and typed Server Actions. Get the…

### {PREREQS}
- Next.js (App Router)
- @tanstack/react-form (TanStack Form)
- Zod
- shadcn/ui
- React
- TypeScript
- Next.js Server Actions
- Payload CMS (login example)

### {STEPS}
1. Create the shared Zod schema and types
2. Build a typed Server Action
3. Implement client using TanStack Form
4. Render fields with shadcn/ui primitives
5. Map server field errors to inputs
6. Clear server errors on input change
7. Test validation, errors, and redirects

<!-- llm:goal="Next.js forms: learn the TanStack Form + Zod + shadcn/ui architecture for field validation, server-error hydration and typed Server Actions. Get the…" -->
<!-- llm:prereq="Next.js (App Router)" -->
<!-- llm:prereq="@tanstack/react-form (TanStack Form)" -->
<!-- llm:prereq="Zod" -->
<!-- llm:prereq="shadcn/ui" -->
<!-- llm:prereq="React" -->
<!-- llm:prereq="TypeScript" -->
<!-- llm:prereq="Next.js Server Actions" -->
<!-- llm:prereq="Payload CMS (login example)" -->

# Next.js Forms: Ultimate TanStack + Zod Pattern (shadcn/ui)
> Next.js forms: learn the TanStack Form + Zod + shadcn/ui architecture for field validation, server-error hydration and typed Server Actions. Get the…
Matija Žiberna · 2026-05-13

_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 `src/components/office/OfficeLoginForm.tsx`. Every new form in this project follows the same shape.

---

## When to use this pattern

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:

1. **Schema layer** (`zod`): defines what valid input looks like
2. **Client form layer** (`@tanstack/react-form`): tracks current field state
3. **UI layer** (`shadcn/ui`): renders fields, labels, and error messages
4. **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:

| Approach | Best for | Main trade-off |
|---|---|---|
| Native `<form action={serverAction}>` | Simple forms, minimal JS | Less control over field-level UX |
| React Hook Form + shadcn docs pattern | Teams already using RHF | Different API shape from TanStack |
| TanStack Form + Zod + shadcn + Server Action | Medium/complex forms with rich UX | More initial setup |
| Client `fetch` to Route Handler | APIs consumed by external clients | Boilerplate for app-internal forms |

---

## File structure

```txt
src/
  components/
    feature/
      feature-form.validation.ts   # Zod schema + shared types
      FeatureForm.tsx              # TanStack + shadcn client form
  app/
    (frontend)/
      some-path/
        actions.ts                 # Server Actions
```

From the canonical example in this project:

- `src/components/office/office-login.validation.ts`
- `src/components/office/OfficeLoginForm.tsx`
- `src/app/(frontend)/narocilnica/auth-actions.ts`

---

## Step 1: Create the shared schema

```ts
// File: src/components/feature/feature-form.validation.ts

import { z } from 'zod';

export const featureFormSchema = z.object({
  email: z.string().trim().email('Vnesite veljaven e-poštni naslov.'),
  password: z.string().min(1, 'Geslo je obvezno.'),
  remember: z.boolean(),
  redirect: z
    .string()
    .trim()
    .refine(
      (value) => value.startsWith('/') && !value.startsWith('//'),
      'Neveljavna preusmeritev.',
    ),
});

export type FeatureFormInput = z.input<typeof featureFormSchema>;
export type FeatureFormValue = z.output<typeof featureFormSchema>;

export type FeatureFormFieldErrors = Partial<
  Record<'email' | 'password' | 'remember' | 'redirect', string[]>
>;

export type FeatureFormActionResult = {
  ok: boolean;
  message: string;
  redirectTo?: string;
  fieldErrors?: FeatureFormFieldErrors;
};
```

`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.

---

## Step 2: Create the typed Server Action

```ts
// File: src/app/(frontend)/some-path/actions.ts

'use server';

import { z } from 'zod';
import { login } from '@payloadcms/next/auth';
import config from '@payload-config';
import {
  featureFormSchema,
  type FeatureFormActionResult,
  type FeatureFormInput,
} from '@/components/feature/feature-form.validation';

export async function submitFeatureForm(
  input: FeatureFormInput,
): Promise<FeatureFormActionResult> {
  const parsed = featureFormSchema.safeParse(input);

  if (!parsed.success) {
    return {
      ok: false,
      message: 'Prosimo popravite označena polja.',
      fieldErrors: z.flattenError(parsed.error).fieldErrors,
    };
  }

  const { email, password, redirect } = parsed.data;
  const allowedRedirects = new Set(['/target', '/narocilnica']);
  const safeRedirect = allowedRedirects.has(redirect) ? redirect : '/target';

  try {
    const result = await login({
      collection: 'users',
      config,
      email,
      password,
    });

    if (!result.token) {
      return { ok: false, message: 'Prijava ni uspela. Preverite svoje podatke.' };
    }
  } catch {
    return {
      ok: false,
      message: 'Napačna e-pošta ali geslo.',
      fieldErrors: {
        email: ['Napačna e-pošta ali geslo.'],
        password: ['Napačna e-pošta ali geslo.'],
      },
    };
  }

  return {
    ok: true,
    message: 'Prijava uspešna.',
    redirectTo: safeRedirect,
  };
}
```

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.

---

## Step 3: Build the client form

```tsx
// File: src/components/feature/FeatureForm.tsx

'use client';

import * as React from 'react';
import { useForm } from '@tanstack/react-form';
import { useRouter } from 'next/navigation';
import { cn } from '@/lib/utils';

import {
  featureFormSchema,
  type FeatureFormActionResult,
  type FeatureFormFieldErrors,
  type FeatureFormValue,
} from '@/components/feature/feature-form.validation';
import { submitFeatureForm } from '@/app/(frontend)/some-path/actions';
import { Button } from '@/components/ui/button';
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';

const defaultValues: FeatureFormValue = {
  email: '',
  password: '',
  remember: false,
  redirect: '/target',
};

function toFieldErrors(errors?: string[]) {
  return errors?.map((message) => ({ message })) ?? [];
}

export function FeatureForm() {
  const router = useRouter();
  const [serverResult, setServerResult] = React.useState<FeatureFormActionResult | null>(null);
  const [serverFieldErrors, setServerFieldErrors] = React.useState<FeatureFormFieldErrors>({});

  const form = useForm({
    defaultValues,
    validators: {
      onSubmit: featureFormSchema,
    },
    onSubmit: async ({ value }) => {
      setServerResult(null);
      setServerFieldErrors({});

      const result = await submitFeatureForm(value);
      setServerResult(result);

      if (!result.ok) {
        setServerFieldErrors(result.fieldErrors ?? {});
        return;
      }

      router.push(result.redirectTo || '/target');
      router.refresh();
    },
  });

  return (
    <form
      id="feature-form"
      onSubmit={(event) => {
        event.preventDefault();
        event.stopPropagation();
        void form.handleSubmit();
      }}
    >
      <FieldGroup className="gap-4">
        <form.Field
          name="email"
          children={(field) => {
            const clientErrors = field.state.meta.errors;
            const serverErrors = toFieldErrors(serverFieldErrors.email);
            const allErrors = [
              ...clientErrors.map((message) => ({ message: String(message) })),
              ...serverErrors,
            ];
            const isInvalid =
              (field.state.meta.isTouched && clientErrors.length > 0) ||
              serverErrors.length > 0;

            return (
              <Field className="gap-2" data-invalid={isInvalid}>
                <FieldLabel htmlFor={field.name}>E-poštni naslov</FieldLabel>
                <Input
                  id={field.name}
                  name={field.name}
                  type="email"
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange={(event) => {
                    setServerFieldErrors((prev) => ({ ...prev, email: undefined }));
                    field.handleChange(event.target.value);
                  }}
                  autoComplete="email"
                  aria-invalid={isInvalid}
                  className={cn(
                    'h-11 rounded-lg',
                    isInvalid && 'border-destructive focus-visible:ring-destructive/20',
                  )}
                />
                <FieldError
                  errors={field.state.meta.isTouched ? allErrors : serverErrors}
                />
              </Field>
            );
          }}
        />

        <form.Field
          name="password"
          children={(field) => {
            const clientErrors = field.state.meta.errors;
            const serverErrors = toFieldErrors(serverFieldErrors.password);
            const allErrors = [
              ...clientErrors.map((message) => ({ message: String(message) })),
              ...serverErrors,
            ];
            const isInvalid =
              (field.state.meta.isTouched && clientErrors.length > 0) ||
              serverErrors.length > 0;

            return (
              <Field className="gap-2" data-invalid={isInvalid}>
                <FieldLabel htmlFor={field.name}>Geslo</FieldLabel>
                <Input
                  id={field.name}
                  name={field.name}
                  type="password"
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange={(event) => {
                    setServerFieldErrors((prev) => ({ ...prev, password: undefined }));
                    field.handleChange(event.target.value);
                  }}
                  autoComplete="current-password"
                  aria-invalid={isInvalid}
                  className={cn(
                    'h-11 rounded-lg',
                    isInvalid && 'border-destructive focus-visible:ring-destructive/20',
                  )}
                />
                <FieldError
                  errors={field.state.meta.isTouched ? allErrors : serverErrors}
                />
              </Field>
            );
          }}
        />
      </FieldGroup>

      {serverResult?.message ? (
        <p role={serverResult.ok ? 'status' : 'alert'}>
          {serverResult.message}
        </p>
      ) : null}

      <form.Subscribe
        selector={(state) => [state.canSubmit, state.isSubmitting]}
        children={([canSubmit, isSubmitting]) => (
          <Button type="submit" disabled={!canSubmit || isSubmitting}>
            {isSubmitting ? 'Shranjujem...' : 'Shrani'}
          </Button>
        )}
      />
    </form>
  );
}
```

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()`:

```ts
.superRefine((value, ctx) => {
  if (!value.phone && !value.email) {
    const message = 'Vnesite telefon ali e-poštni naslov.';
    ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['phone'], message });
    ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['email'], message });
  }
});
```

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:

1. Create `*.validation.ts` with the Zod schema, input type, result type, and field error type
2. Create a typed Server Action that calls `safeParse`, handles auth/authz, and returns a `FormActionResult`
3. Build the client form with `useForm`, map `fieldErrors` from the result to per-field state
4. Clear server errors on field change
5. Use `aria-invalid` and `data-invalid` on every Field
6. Derive submit button state from `canSubmit` and `isSubmitting`
7. 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