BuildWithMatija
  1. Home
  2. Blog
  3. Next.js
  4. Next.js Forms: Ultimate TanStack + Zod Pattern (shadcn/ui)

Next.js Forms: Ultimate TanStack + Zod Pattern (shadcn/ui)

Implement field-level validation, server error hydration, typed Server Actions, and accessible shadcn/ui fields for…

13th May 2026·Updated on:15th May 2026··
Next.js
Next.js Forms: Ultimate TanStack + Zod Pattern (shadcn/ui)

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

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

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.

Contents

  • When to use this pattern
  • The mental model
  • Other form approaches in Next.js App Router
  • File structure
  • Step 1: Create the shared schema
  • Step 2: Create the typed Server Action
  • Step 3: Build the client form
  • Handling cross-field validation
  • Checklist for new forms
  • Common mistakes
  • FAQ
  • Conclusion
On this page:
  • When to use this pattern
  • The mental model
  • Other form approaches in Next.js App Router
  • File structure
  • Step 1: Create the shared schema
Build with Matija logo

Build with Matija

Modern websites, content systems, and AI workflows built for long-term growth.

Services

  • Headless CMS Websites
  • Next.js & Headless CMS Advisory
  • AI Systems & Automation
  • Website & Content Audit

Resources

  • Case Studies
  • How I Work
  • Blog
  • CMS Hub
  • E-commerce Hub
  • Dashboard

Headless CMS

  • Payload CMS Developer
  • CMS Migration
  • Multi-Tenant CMS
  • Payload vs Sanity
  • Payload vs WordPress
  • Payload vs Contentful

Get in Touch

Ready to modernize your stack? Let's talk about what you're building.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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:

RequirementUse this pattern
Field-level client validationYes
Server errors displayed under specific inputsYes
Loading or disabled submit stateYes
Typed action results with redirectYes
Shared schema on client and serverYes
Interactive or conditional fieldsYes

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:

ApproachBest forMain trade-off
Native <form action={serverAction}>Simple forms, minimal JSLess control over field-level UX
React Hook Form + shadcn docs patternTeams already using RHFDifferent API shape from TanStack
TanStack Form + Zod + shadcn + Server ActionMedium/complex forms with rich UXMore initial setup
Client fetch to Route HandlerAPIs consumed by external clientsBoilerplate 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