---
title: "Build a Multi-Step TanStack Form Wizard in Next.js"
slug: "tanstack-form-wizard-nextjs"
published: "2026-05-24"
updated: "2026-05-29"
validated: "2026-05-29"
categories:
  - "Next.js"
tags:
  - "tanstack form wizard"
  - "tanstack react form"
  - "next.js onboarding wizard"
  - "zod validation"
  - "multi-step form"
  - "autosave localStorage"
  - "server actions"
  - "conditional steps"
  - "wizard pattern"
  - "useWizardForm"
  - "form persistence"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "tanstack form"
  - "zod"
  - "next.js (app router, server actions)"
  - "typescript"
  - "react"
status: "stable"
llm-purpose: "TanStack Form wizard for Next.js: step-by-step guide with Zod validation, autosave drafts, conditional steps, and single-server submit—start implementing…"
llm-prereqs:
  - "Access to TanStack Form"
  - "Access to Zod"
  - "Access to Next.js (App Router, Server Actions)"
  - "Access to TypeScript"
  - "Access to React"
llm-outputs:
  - "Completed outcome: TanStack Form wizard for Next.js: step-by-step guide with Zod validation, autosave drafts, conditional steps, and single-server submit—start implementing…"
---

**Summary Triples**
- (form instance, holds, all step data in one TanStack Form instance)
- (steps, can be, shown or hidden dynamically based on earlier answers (conditional visibility))
- (validation, is performed, one step at a time using Zod and errors are mapped back into the form)
- (persistence, autosaves to, localStorage via wizard-persist.ts to preserve drafts across refreshes)
- (submission, is done, as a single server action (wizard.actions.ts) sending the full wizard payload)
- (navigation, is handled by, useWizardStep hook which respects validation and conditional steps)
- (form hook, exposes, helpers to validate single steps, set values, and subscribe to persistence events (useWizardForm.ts))
- (server errors, are, mapped back into the form fields so users can correct them before resubmitting)
- (example flow, includes, plan → account → billing → team (conditional) → review steps)
- (array steps, are supported, via an advanced pattern described for collection-style inputs)

### {GOAL}
TanStack Form wizard for Next.js: step-by-step guide with Zod validation, autosave drafts, conditional steps, and single-server submit—start implementing…

### {PREREQS}
- Access to TanStack Form
- Access to Zod
- Access to Next.js (App Router, Server Actions)
- Access to TypeScript
- Access to React

### {STEPS}
1. Install dependencies and assumptions
2. Create folder and file structure
3. Define shared types and form shape
4. Configure steps and visibility rules
5. Write per-step and full Zod schemas
6. Build a single useForm factory
7. Implement step navigation and validation
8. Create step UI components
9. Persist drafts safely to localStorage
10. Wire the WizardShell and autosave
11. Submit via server action and map errors
12. Add advanced features and dev tools

<!-- llm:goal="TanStack Form wizard for Next.js: step-by-step guide with Zod validation, autosave drafts, conditional steps, and single-server submit—start implementing…" -->
<!-- llm:prereq="Access to TanStack Form" -->
<!-- llm:prereq="Access to Zod" -->
<!-- llm:prereq="Access to Next.js (App Router, Server Actions)" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to React" -->
<!-- llm:output="Completed outcome: TanStack Form wizard for Next.js: step-by-step guide with Zod validation, autosave drafts, conditional steps, and single-server submit—start implementing…" -->

# Build a Multi-Step TanStack Form Wizard in Next.js
> TanStack Form wizard for Next.js: step-by-step guide with Zod validation, autosave drafts, conditional steps, and single-server submit—start implementing…
Matija Žiberna · 2026-05-24

A practical follow-along guide extracted from a production onboarding wizard. By the end you will have a working, copy-pasteable pattern for a wizard that:

- holds **all step data in one TanStack Form instance**
- shows or hides steps **dynamically** based on earlier answers
- validates **one step at a time** with Zod, mapping errors back into the form
- **autosaves** progress to `localStorage` so a browser refresh does not lose work
- **submits everything in a single server action** at the end

The example builds a subscription onboarding flow: **plan → account → billing → team (conditional) → review**. The team step only appears when the user picks a multi-user plan. Swap the domain details for your own use case.!

<video autoPlay controls width="50%" class="my-8 mx-auto rounded-lg">
  <source src="https://cdn.sanity.io/files/ytvlzq9e/production/5f3b3c827d449817bc55c5e2993183becc94dfe7.mp4" type="video/mp4" />
  Your browser does not support the video tag.
</video>

---

## What this guide covers

1. [Requirements and assumptions](#1-requirements-and-assumptions)
2. [Folder structure](#2-folder-structure)
3. [Types — `wizard.types.ts`](#3-types--wizardtypests)
4. [Step configuration — `wizard-step-config.ts`](#4-step-configuration--wizard-step-configts)
5. [Validation — `wizard.validation.ts`](#5-validation--wizardvalidationts)
6. [Form hook — `useWizardForm.ts`](#6-form-hook--usewizardformts)
7. [Step navigation hook — `useWizardStep.ts`](#7-step-navigation-hook--usewizardstepts)
8. [Step components](#8-step-components) — StepAccount, StepPlan, StepBilling, StepReview (all included)
9. [Draft persistence — `wizard-persist.ts`](#9-draft-persistence--wizard-persistts)
10. [Shell component — `WizardShell.tsx`](#10-shell-component--wizardshelltsx)
11. [Server action — `wizard.actions.ts`](#11-server-action--wizardactionsts)
12. [Mapping server errors back into the form](#12-mapping-server-errors-back-into-the-form)
13. [Advanced: array steps](#13-advanced-array-steps)
14. [Advanced: credential sidecar](#14-advanced-credential-sidecar)
15. [Advanced: dev debug panel](#15-advanced-dev-debug-panel)
16. [Adding a new step — checklist](#16-adding-a-new-step--checklist)
17. [Common pitfalls](#17-common-pitfalls)

---

## 1. Requirements and assumptions

### Install dependencies

```bash
pnpm add @tanstack/react-form zod lucide-react
# or npm / yarn equivalents
```

### Assumptions

- **Next.js App Router** with Server Actions enabled (`'use server'` directive)
- **TypeScript** with strict mode recommended
- React client components for the wizard UI (`'use client'`)
- UI primitives (`Field`, `FieldLabel`, `FieldError`, `Input`) — examples use shadcn-style components, but they are thin wrappers. Replace them with any component library or inline HTML. The pattern does not depend on them.

### What `Field` / `FieldError` look like

If you do not have these components, the minimal versions below are enough to follow along:

```tsx
// components/ui/field.tsx
export function Field({ children, className }: React.PropsWithChildren<{ className?: string }>) {
  return <div className={`flex flex-col gap-1 ${className ?? ''}`}>{children}</div>
}

export function FieldLabel(props: React.LabelHTMLAttributes<HTMLLabelElement>) {
  return <label {...props} className={`text-sm font-medium ${props.className ?? ''}`} />
}

export function FieldError({ errors }: { errors?: Array<{ message: string } | string> }) {
  const messages = (errors ?? [])
    .map((e) => (typeof e === 'string' ? e : e.message))
    .filter(Boolean)
  if (messages.length === 0) return null
  return <p className="text-sm text-red-600">{messages[0]}</p>
}
```

---

## 2. Folder structure

```
src/wizard/
├── wizard.types.ts             # Shared TypeScript types
├── wizard-step-config.ts       # Step list and visibility rules
├── wizard.validation.ts        # Zod schemas — one per step + full form
├── useWizardForm.ts            # TanStack Form factory and defaults
├── useWizardStep.ts            # Step navigation and per-step validation
├── wizard-persist.ts           # localStorage draft: save / load / clear
├── wizard.actions.ts           # Server action: submitWizard()
├── WizardShell.tsx             # Top-level client component
└── steps/
    ├── StepPlan.tsx
    ├── StepAccount.tsx
    ├── StepBilling.tsx
    ├── StepTeam.tsx
    └── StepReview.tsx
```

---

## 3. Types — `wizard.types.ts`

Define the full form shape in one place. Every other file imports from here.

```typescript
// wizard.types.ts

export type Plan = 'solo' | 'team' | 'enterprise'
export type BillingInterval = 'monthly' | 'annual'

export interface TeamMember {
  id: string      // client-side key for React rendering
  email: string
  role: 'admin' | 'member'
}

export interface WizardFormValues {
  // Step: plan
  plan: Plan

  // Step: account
  email: string
  password: string
  confirmPassword: string   // never saved to localStorage; stripped before the server call (see §11)

  // Step: billing
  billingInterval: BillingInterval

  // Step: team (conditional — only when plan !== 'solo')
  teamName: string
  teamSlug: string
  teamMembers: TeamMember[]
}

// Sent to the server — confirmPassword is stripped before the call.
// The server never needs to see it: match validation is client-side only.
export type WizardServerInput = Omit<WizardFormValues, 'confirmPassword'>
```

---

## 4. Step configuration — `wizard-step-config.ts`

A single file owns the step list and all visibility rules. `getVisibleSteps` is a pure function — no state, no side effects — called on every render.

```typescript
// wizard-step-config.ts
import type { WizardFormValues } from './wizard.types'

export type StepId =
  | 'plan'
  | 'account'
  | 'billing'
  | 'team'
  | 'review'

export interface StepConfig {
  id: StepId
  always: boolean
  visible: (values: WizardFormValues) => boolean
}

export const STEP_CONFIGS: StepConfig[] = [
  { id: 'plan',    always: true,  visible: () => true },
  { id: 'account', always: true,  visible: () => true },
  { id: 'billing', always: true,  visible: () => true },
  {
    id: 'team',
    always: false,
    visible: (v) => v.plan === 'team' || v.plan === 'enterprise',
  },
  { id: 'review',  always: true,  visible: () => true },
]

export function getVisibleSteps(values: WizardFormValues): StepConfig[] {
  return STEP_CONFIGS.filter((s) => s.always || s.visible(values))
}
```

**Key rule:** When the user goes back and changes their plan from `team` to `solo`, the team step disappears from `visibleSteps` immediately. The step index is always an index into this filtered list, not a fixed number.

---

## 5. Validation — `wizard.validation.ts`

One Zod schema per step, applied to the **full** form values. The step schema only validates the fields it owns — unrelated fields are ignored by Zod.

```typescript
// wizard.validation.ts
import { z } from 'zod'

// ── Per-step schemas ─────────────────────────────────────────────────────

export const stepPlanSchema = z.object({
  plan: z.enum(['solo', 'team', 'enterprise'], {
    errorMap: () => ({ message: 'Please select a plan.' }),
  }),
})

export const stepAccountSchema = z
  .object({
    email: z.string().email('Enter a valid email address.'),
    password: z.string().min(8, 'Password must be at least 8 characters.'),
    confirmPassword: z.string(),
  })
  .refine((v) => v.password === v.confirmPassword, {
    message: 'Passwords do not match.',
    path: ['confirmPassword'],
  })

export const stepBillingSchema = z.object({
  billingInterval: z.enum(['monthly', 'annual'], {
    errorMap: () => ({ message: 'Please select a billing interval.' }),
  }),
})

export const stepTeamSchema = z.object({
  teamName: z.string().min(1, 'Team name is required.'),
  teamSlug: z
    .string()
    .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Use lowercase letters, numbers, and hyphens only.'),
})

// ── Full schema (used at final submit on the server) ────────────────────
//
// confirmPassword is intentionally absent — it is stripped by the client
// before calling submitWizard() and is never validated server-side.
// The match check is done at the account step (stepAccountSchema).
//
// Uses superRefine so conditional validation (team fields required when
// plan !== 'solo') can be expressed in one place.
export const fullWizardSchema = z
  .object({
    plan: z.enum(['solo', 'team', 'enterprise']),
    email: z.string().email(),
    password: z.string().min(8),
    billingInterval: z.enum(['monthly', 'annual']),
    teamName: z.string().optional(),
    teamSlug: z.string().optional(),
    teamMembers: z
      .array(
        z.object({
          id: z.string(),
          email: z.string().email('Each member needs a valid email.'),
          role: z.enum(['admin', 'member']),
        }),
      )
      .optional(),
  })
  .superRefine((data, ctx) => {
    // Team fields required when plan is not solo
    if (data.plan !== 'solo') {
      if (!data.teamName?.trim()) {
        ctx.addIssue({ code: 'custom', path: ['teamName'], message: 'Team name is required.' })
      }
      if (!data.teamSlug?.trim()) {
        ctx.addIssue({ code: 'custom', path: ['teamSlug'], message: 'Team slug is required.' })
      }
    }
  })
```

---

## 6. Form hook — `useWizardForm.ts`

One `useForm` call for the entire wizard. No `onSubmit` at the form level — each step validates independently, and the final submit is triggered explicitly.

```typescript
// useWizardForm.ts
'use client'

import { useForm } from '@tanstack/react-form'
import type { WizardFormValues } from './wizard.types'

export const defaultWizardValues: WizardFormValues = {
  plan: 'solo',
  email: '',
  password: '',
  confirmPassword: '',
  billingInterval: 'monthly',
  teamName: '',
  teamSlug: '',
  teamMembers: [],
}

export function useWizardForm(
  initialValues: WizardFormValues = defaultWizardValues,
) {
  return useForm({ defaultValues: initialValues })
}

// Exported type so step components can be typed without importing useForm directly
export type WizardForm = ReturnType<typeof useWizardForm>

export function resetWizardForm(form: WizardForm): WizardFormValues {
  form.reset(defaultWizardValues)
  return defaultWizardValues
}
```

---

## 7. Step navigation hook — `useWizardStep.ts`

This hook manages: visible steps, current step index, completed-step tracking, forward/back navigation, and per-step Zod validation.

The complete file:

```typescript
// useWizardStep.ts
'use client'

import { useState, useCallback, useMemo, useEffect } from 'react'
import type { ZodTypeAny } from 'zod'
import { getVisibleSteps, type StepId } from './wizard-step-config'
import type { WizardFormValues } from './wizard.types'
import type { WizardForm } from './useWizardForm'
import {
  stepPlanSchema,
  stepAccountSchema,
  stepBillingSchema,
  stepTeamSchema,
} from './wizard.validation'

// ── Validator and field-prefix registries ────────────────────────────────

const stepValidators: Record<Exclude<StepId, 'review'>, ZodTypeAny> = {
  plan:    stepPlanSchema,
  account: stepAccountSchema,
  billing: stepBillingSchema,
  team:    stepTeamSchema,
}

// Each step owns these field name prefixes.
// Errors are cleared and set only for the active step's prefixes,
// so stale errors from other steps never leak through.
const stepFieldPrefixes: Record<Exclude<StepId, 'review'>, string[]> = {
  plan:    ['plan'],
  account: ['email', 'password', 'confirmPassword'],
  billing: ['billingInterval'],
  team:    ['teamName', 'teamSlug', 'teamMembers'],
}

// ── Helpers ──────────────────────────────────────────────────────────────

function matchesFieldPrefix(fieldName: string, prefix: string): boolean {
  return (
    fieldName === prefix ||
    fieldName.startsWith(`${prefix}.`) ||
    fieldName.startsWith(`${prefix}[`)
  )
}

function pathToFieldName(path: ReadonlyArray<PropertyKey>): string {
  return path.reduce<string>((acc, part) => {
    if (typeof part === 'number') return `${acc}[${part}]`
    if (typeof part !== 'string') return acc
    return acc ? `${acc}.${part}` : part
  }, '')
}

function clearStepErrors(form: WizardForm, stepId: Exclude<StepId, 'review'>) {
  const prefixes = stepFieldPrefixes[stepId]
  for (const fieldName of Object.keys(form.state.fieldMeta)) {
    if (!prefixes.some((p) => matchesFieldPrefix(fieldName, p))) continue
    form.setFieldMeta(fieldName as never, (prev) => ({
      ...prev,
      errorMap: { ...prev?.errorMap, onSubmit: undefined },
    }))
  }
}

function applyStepErrors(
  form: WizardForm,
  errors: Record<string, string[]>,
  stepId: Exclude<StepId, 'review'>,
) {
  const rootField = stepFieldPrefixes[stepId][0]

  for (const [fieldName, messages] of Object.entries(errors)) {
    form.setFieldMeta(fieldName as never, (prev) => ({
      ...prev,
      isTouched: true,
      errorMap: {
        ...prev?.errorMap,
        onSubmit: messages.map((message) => ({ message })),
      },
    }))
  }

  // Ensure the root field is also touched so the step component knows to show errors
  if (!Object.keys(errors).includes(rootField)) {
    form.setFieldMeta(rootField as never, (prev) => ({
      ...prev,
      isTouched: true,
    }))
  }
}

// ── Hook ─────────────────────────────────────────────────────────────────

interface UseWizardStepOptions {
  formValues: WizardFormValues
  initialStep?: { currentStepIndex: number; completedStepIds: StepId[] }
}

export function useWizardStep({ formValues, initialStep }: UseWizardStepOptions) {
  const [currentStepIndex, setCurrentStepIndex] = useState(
    () => initialStep?.currentStepIndex ?? 0,
  )
  const [completedSteps, setCompletedSteps] = useState<Set<StepId>>(
    () => new Set(initialStep?.completedStepIds ?? []),
  )

  // Pure function — recomputes from live form values on every render
  const visibleSteps = useMemo(() => getVisibleSteps(formValues), [formValues])

  // Clamp index when steps disappear (e.g. user goes back and changes plan)
  useEffect(() => {
    if (currentStepIndex >= visibleSteps.length && visibleSteps.length > 0) {
      const newIndex = visibleSteps.length - 1
      setCurrentStepIndex(newIndex)
      setCompletedSteps((old) => {
        const allowed = new Set(visibleSteps.slice(0, newIndex).map((s) => s.id))
        return new Set([...old].filter((id) => allowed.has(id)))
      })
    }
  }, [currentStepIndex, visibleSteps])

  const currentStep = visibleSteps[currentStepIndex]
  const isFirstStep = currentStepIndex === 0
  const isLastStep  = currentStepIndex === visibleSteps.length - 1

  // ── Per-step validation ─────────────────────────────────────────────

  const validateCurrentStep = useCallback(
    async (form: WizardForm): Promise<boolean> => {
      if (!currentStep || currentStep.id === 'review') return true

      clearStepErrors(form, currentStep.id)

      const result = stepValidators[currentStep.id].safeParse(form.state.values)

      if (!result.success) {
        const errors = result.error.issues.reduce<Record<string, string[]>>((acc, issue) => {
          const key = pathToFieldName(issue.path) || stepFieldPrefixes[currentStep.id][0]
          acc[key] = [...(acc[key] ?? []), issue.message]
          return acc
        }, {})
        applyStepErrors(form, errors, currentStep.id)
        return false
      }

      return true
    },
    [currentStep],
  )

  // ── Navigation ──────────────────────────────────────────────────────

  const goNext = useCallback(() => {
    if (!isLastStep && currentStep) {
      setCompletedSteps((prev) => new Set(prev).add(currentStep.id))
      setCurrentStepIndex((prev) => Math.min(prev + 1, visibleSteps.length - 1))
    }
  }, [isLastStep, currentStep, visibleSteps.length])

  const goBack = useCallback(() => {
    if (isFirstStep) return
    const nextIndex = Math.max(currentStepIndex - 1, 0)
    setCompletedSteps((old) => {
      const allowed = new Set(visibleSteps.slice(0, nextIndex).map((s) => s.id))
      return new Set([...old].filter((id) => allowed.has(id)))
    })
    setCurrentStepIndex(nextIndex)
  }, [isFirstStep, currentStepIndex, visibleSteps])

  const resetStepNavigation = useCallback(() => {
    setCurrentStepIndex(0)
    setCompletedSteps(new Set())
  }, [])

  return {
    currentStepIndex,
    currentStep,
    visibleSteps,
    completedSteps,
    isFirstStep,
    isLastStep,
    validateCurrentStep,
    goNext,
    goBack,
    resetStepNavigation,
  }
}
```

---

## 8. Step components

Each step receives `form: WizardForm` as a prop and uses TanStack Form's render-props API.

### Basic field pattern

```tsx
// steps/StepAccount.tsx
'use client'

import { Field, FieldLabel, FieldError } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import type { WizardForm } from '../useWizardForm'

export function StepAccount({ form }: { form: WizardForm }) {
  return (
    <div className="space-y-4">
      <form.Field name="email">
        {(field) => (
          <Field>
            <FieldLabel htmlFor={field.name}>Email address</FieldLabel>
            <Input
              id={field.name}
              type="email"
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
            />
            <FieldError errors={field.state.meta.errors} />
          </Field>
        )}
      </form.Field>

      <form.Field name="password">
        {(field) => (
          <Field>
            <FieldLabel htmlFor={field.name}>Password</FieldLabel>
            <Input
              id={field.name}
              type="password"
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
            />
            <FieldError errors={field.state.meta.errors} />
          </Field>
        )}
      </form.Field>

      <form.Field name="confirmPassword">
        {(field) => (
          <Field>
            <FieldLabel htmlFor={field.name}>Confirm password</FieldLabel>
            <Input
              id={field.name}
              type="password"
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
            />
            <FieldError errors={field.state.meta.errors} />
          </Field>
        )}
      </form.Field>
    </div>
  )
}
```

### Remaining step components

**`StepPlan.tsx`** — radio group picking a plan:

```tsx
// steps/StepPlan.tsx
'use client'

import { Field, FieldLabel, FieldError } from '@/components/ui/field'
import type { WizardForm } from '../useWizardForm'
import type { Plan } from '../wizard.types'

const PLANS: { value: Plan; label: string; description: string }[] = [
  { value: 'solo',       label: 'Solo',       description: 'Just you.' },
  { value: 'team',       label: 'Team',       description: 'Up to 10 members.' },
  { value: 'enterprise', label: 'Enterprise', description: 'Unlimited members.' },
]

export function StepPlan({ form }: { form: WizardForm }) {
  return (
    <form.Field name="plan">
      {(field) => (
        <Field>
          <FieldLabel>Choose a plan</FieldLabel>
          <div className="space-y-2">
            {PLANS.map((plan) => (
              <label
                key={plan.value}
                className="flex cursor-pointer items-start gap-3 rounded border p-3 hover:bg-muted"
              >
                <input
                  type="radio"
                  name={field.name}
                  value={plan.value}
                  checked={field.state.value === plan.value}
                  onChange={() => field.handleChange(plan.value)}
                  className="mt-0.5"
                />
                <div>
                  <p className="font-medium">{plan.label}</p>
                  <p className="text-sm text-muted-foreground">{plan.description}</p>
                </div>
              </label>
            ))}
          </div>
          <FieldError errors={field.state.meta.errors} />
        </Field>
      )}
    </form.Field>
  )
}
```

**`StepBilling.tsx`** — toggle between monthly and annual:

```tsx
// steps/StepBilling.tsx
'use client'

import { Field, FieldLabel, FieldError } from '@/components/ui/field'
import type { WizardForm } from '../useWizardForm'
import type { BillingInterval } from '../wizard.types'

const OPTIONS: { value: BillingInterval; label: string }[] = [
  { value: 'monthly', label: 'Monthly' },
  { value: 'annual',  label: 'Annual (save 20%)' },
]

export function StepBilling({ form }: { form: WizardForm }) {
  return (
    <form.Field name="billingInterval">
      {(field) => (
        <Field>
          <FieldLabel>Billing interval</FieldLabel>
          <div className="flex gap-3">
            {OPTIONS.map((opt) => (
              <label
                key={opt.value}
                className="flex cursor-pointer items-center gap-2 rounded border px-4 py-2"
              >
                <input
                  type="radio"
                  name={field.name}
                  value={opt.value}
                  checked={field.state.value === opt.value}
                  onChange={() => field.handleChange(opt.value)}
                />
                {opt.label}
              </label>
            ))}
          </div>
          <FieldError errors={field.state.meta.errors} />
        </Field>
      )}
    </form.Field>
  )
}
```

**`StepReview.tsx`** — read-only summary before submit:

```tsx
// steps/StepReview.tsx
'use client'

import { useStore } from '@tanstack/react-form'
import type { WizardForm } from '../useWizardForm'

export function StepReview({ form }: { form: WizardForm }) {
  const values = useStore(form.store, (s) => s.values)

  return (
    <div className="space-y-4">
      <h2 className="text-lg font-semibold">Review your details</h2>
      <dl className="divide-y rounded border">
        <Row label="Plan"    value={values.plan} />
        <Row label="Email"   value={values.email} />
        <Row label="Billing" value={values.billingInterval} />
        {values.plan !== 'solo' && (
          <>
            <Row label="Team name" value={values.teamName} />
            <Row label="Team slug" value={values.teamSlug} />
            <Row
              label="Members"
              value={values.teamMembers.length > 0
                ? values.teamMembers.map((m) => m.email).join(', ')
                : 'None added'}
            />
          </>
        )}
      </dl>
      <p className="text-sm text-muted-foreground">
        Click Submit to create your account.
      </p>
    </div>
  )
}

function Row({ label, value }: { label: string; value: string }) {
  return (
    <div className="flex justify-between px-3 py-2 text-sm">
      <dt className="font-medium text-muted-foreground">{label}</dt>
      <dd>{value || '—'}</dd>
    </div>
  )
}
```

---

### Watching dependent values

Use `form.Subscribe` to re-render only when specific values change:

```tsx
<form.Subscribe selector={(s) => s.values.plan}>
  {(plan) =>
    plan === 'enterprise' && (
      <p className="text-sm text-muted-foreground">
        Enterprise billing is invoiced annually.
      </p>
    )
  }
</form.Subscribe>
```

### Select / RadioGroup type casting

`onValueChange` from shadcn Select emits `string`, but the field expects the narrower union type. Cast it:

```tsx
<Select
  value={field.state.value}
  onValueChange={(v) => field.handleChange(v as typeof field.state.value)}
>
```

---

## 9. Draft persistence — `wizard-persist.ts`

**Critical:** never write `password` or `confirmPassword` to `localStorage`. Strip them before saving.

```typescript
// wizard-persist.ts
import type { WizardFormValues } from './wizard.types'
import type { StepId } from './wizard-step-config'

const STORAGE_VERSION = 1 as const
const STORAGE_KEY = `my-wizard:v${STORAGE_VERSION}`

interface WizardDraft {
  version: typeof STORAGE_VERSION
  currentStepIndex: number
  completedStepIds: StepId[]
  values: Partial<Omit<WizardFormValues, 'password' | 'confirmPassword'>>
}

// Strip passwords before they can reach localStorage
function sanitizeDraftValues(
  values: WizardFormValues,
): Partial<Omit<WizardFormValues, 'password' | 'confirmPassword'>> {
  const { password: _p, confirmPassword: _c, ...safeValues } = values
  return safeValues
}

export function saveWizardDraft(params: {
  values: WizardFormValues
  currentStepIndex: number
  completedStepIds: StepId[]
}) {
  try {
    const envelope: WizardDraft = {
      version: STORAGE_VERSION,
      currentStepIndex: params.currentStepIndex,
      completedStepIds: params.completedStepIds,
      values: sanitizeDraftValues(params.values),
    }
    localStorage.setItem(STORAGE_KEY, JSON.stringify(envelope))
  } catch {
    // localStorage may be unavailable (private browsing, storage full)
  }
}

export function loadWizardDraft(): WizardDraft | null {
  try {
    const raw = localStorage.getItem(STORAGE_KEY)
    if (!raw) return null
    const parsed = JSON.parse(raw) as unknown
    if (
      !parsed ||
      typeof parsed !== 'object' ||
      (parsed as WizardDraft).version !== STORAGE_VERSION
    ) {
      return null
    }
    return parsed as WizardDraft
  } catch {
    return null
  }
}

export function clearWizardDraft() {
  try { localStorage.removeItem(STORAGE_KEY) } catch {}
}

// Merge persisted values with defaults.
// Passwords always start empty — they were never stored.
export function mergePersistedValues(
  partial: Partial<WizardFormValues> | undefined,
  defaults: WizardFormValues,
): WizardFormValues {
  return {
    ...defaults,
    ...partial,
    // These are never persisted, always reset
    password: '',
    confirmPassword: '',
  }
}
```

**Use a relaxed draft schema if you add Zod validation to the load path.** Work-in-progress data (empty strings, zeroes) would fail the strict submit-time schema and silently result in an empty draft. Keep draft validation permissive and save submit-time strictness for `fullWizardSchema`.

---

## 10. Shell component — `WizardShell.tsx`

The shell wires everything together: hydration, autosave, step rendering, navigation, and submit.

```tsx
// WizardShell.tsx
'use client'

import { useState, useLayoutEffect, useEffect, useRef } from 'react'
import { useStore } from '@tanstack/react-form'
import { Loader2 } from 'lucide-react'
import { useWizardForm, defaultWizardValues } from './useWizardForm'
import type { WizardForm } from './useWizardForm'
import { useWizardStep } from './useWizardStep'
import {
  loadWizardDraft,
  saveWizardDraft,
  clearWizardDraft,
  mergePersistedValues,
} from './wizard-persist'
import { submitWizard } from './wizard.actions'
import { applyServerErrors } from './wizard-error-mapping'  // see §12
import { StepPlan } from './steps/StepPlan'
import { StepAccount } from './steps/StepAccount'
import { StepBilling } from './steps/StepBilling'
import { StepTeam } from './steps/StepTeam'
import { StepReview } from './steps/StepReview'
import type { StepId } from './wizard-step-config'
import type { WizardFormValues } from './wizard.types'

const STEP_COMPONENTS: Record<Exclude<StepId, 'review'>, React.ComponentType<{ form: WizardForm }>> = {
  plan:    StepPlan,
  account: StepAccount,
  billing: StepBilling,
  team:    StepTeam,
}

// ── Outer shell — handles hydration ─────────────────────────────────────

export function WizardShell() {
  const [hydrated, setHydrated] = useState(false)
  const [initialValues, setInitialValues] = useState<WizardFormValues | null>(null)
  const [initialNav, setInitialNav] = useState<{
    currentStepIndex: number
    completedStepIds: StepId[]
  } | undefined>()

  // useLayoutEffect fires before paint, so the form is initialized
  // with correct values before the first render — no flash of empty state.
  useLayoutEffect(() => {
    const draft = loadWizardDraft()
    if (draft) {
      setInitialValues(mergePersistedValues(draft.values, defaultWizardValues))
      setInitialNav({
        currentStepIndex: draft.currentStepIndex,
        completedStepIds: draft.completedStepIds,
      })
    } else {
      setInitialValues({ ...defaultWizardValues })
    }
    setHydrated(true)
  }, [])

  if (!hydrated || !initialValues) {
    return (
      <div className="flex min-h-screen items-center justify-center">
        <Loader2 className="size-8 animate-spin text-muted-foreground" aria-label="Loading" />
      </div>
    )
  }

  return <WizardBody initialValues={initialValues} initialNav={initialNav} />
}

// ── Inner body — owns form, nav, and autosave ────────────────────────────

function WizardBody({
  initialValues,
  initialNav,
}: {
  initialValues: WizardFormValues
  initialNav?: { currentStepIndex: number; completedStepIds: StepId[] }
}) {
  const form = useWizardForm(initialValues)
  const [submitted, setSubmitted]     = useState(false)
  const [submitting, setSubmitting]   = useState(false)
  const [validating, setValidating]   = useState(false)
  const [submitError, setSubmitError] = useState<string | null>(null)

  const formValues = useStore(form.store, (s) => s.values)

  const {
    currentStepIndex,
    currentStep,
    visibleSteps,
    completedSteps,
    isLastStep,
    validateCurrentStep,
    goNext,
    goBack,
  } = useWizardStep({ formValues, initialStep: initialNav })

  // ── Autosave ────────────────────────────────────────────────────────

  // Snapshot ref so event-listener callbacks always read current values
  // without being re-registered on every render.
  const snapshotRef = useRef<{
    form: WizardForm
    currentStepIndex: number
    completedStepIds: StepId[]
  } | null>(null)
  snapshotRef.current = {
    form,
    currentStepIndex,
    completedStepIds: Array.from(completedSteps),
  }

  // Flush on tab switch or browser close
  useEffect(() => {
    const flush = () => {
      if (submitted) return
      const snap = snapshotRef.current
      if (!snap) return
      saveWizardDraft({
        values: snap.form.state.values,
        currentStepIndex: snap.currentStepIndex,
        completedStepIds: snap.completedStepIds,
      })
    }

    const handleVisibilityChange = () => {
      if (document.visibilityState === 'hidden') flush()
    }

    window.addEventListener('pagehide', flush)
    document.addEventListener('visibilitychange', handleVisibilityChange)

    return () => {
      window.removeEventListener('pagehide', flush)
      document.removeEventListener('visibilitychange', handleVisibilityChange)
    }
  }, [submitted])

  // Debounced save on every value change
  useEffect(() => {
    if (submitted) return
    const id = window.setTimeout(() => {
      saveWizardDraft({
        values: formValues,
        currentStepIndex,
        completedStepIds: Array.from(completedSteps),
      })
    }, 350)
    return () => window.clearTimeout(id)
  }, [submitted, formValues, currentStepIndex, completedSteps])

  // ── Navigation handlers ─────────────────────────────────────────────

  const handleNext = async () => {
    if (validating) return
    setSubmitError(null)
    setValidating(true)
    try {
      const valid = await validateCurrentStep(form)
      if (!valid) return
    } finally {
      setValidating(false)
    }
    goNext()
  }

  const handleBack = () => {
    if (validating) return
    setSubmitError(null)
    goBack()
  }

  // ── Submit ──────────────────────────────────────────────────────────

  const handleSubmit = async () => {
    if (submitting || validating) return
    setSubmitError(null)

    // Validate the last step before submitting
    if (currentStep && currentStep.id !== 'review') {
      setValidating(true)
      try {
        const valid = await validateCurrentStep(form)
        if (!valid) return
      } finally {
        setValidating(false)
      }
    }

    setSubmitting(true)
    try {
      // Strip confirmPassword — it was validated client-side and must not be sent to the server
      const { confirmPassword: _c, ...serverInput } = form.state.values
      const result = await submitWizard(serverInput)

      if (result.ok) {
        setSubmitted(true)
        clearWizardDraft()
        if (result.redirectUrl) {
          window.location.assign(result.redirectUrl)
          return
        }
        return
      }

      // Map server field errors back into the form (see §12)
      if (result.fieldErrors) {
        applyServerErrors(form, result.fieldErrors)
      }
      setSubmitError(result.message ?? 'Submission failed. Please try again.')
    } finally {
      setSubmitting(false)
    }
  }

  // ── Render ──────────────────────────────────────────────────────────

  const StepComponent =
    currentStep && currentStep.id !== 'review'
      ? STEP_COMPONENTS[currentStep.id]
      : null

  const progress =
    visibleSteps.length > 0
      ? Math.round(((currentStepIndex + 1) / visibleSteps.length) * 100)
      : 0

  return (
    <div className="flex min-h-screen flex-col">
      {/* Progress header */}
      <header className="border-b px-4 py-3">
        <div className="mx-auto max-w-xl">
          <p className="text-sm text-muted-foreground">
            Step {currentStepIndex + 1} of {visibleSteps.length}
          </p>
          <div className="mt-1 h-1 rounded-full bg-muted">
            <div
              className="h-1 rounded-full bg-primary transition-all"
              style={{ width: `${progress}%` }}
            />
          </div>
        </div>
      </header>

      {/* Step content */}
      <main className="mx-auto w-full max-w-xl flex-1 px-4 py-8">
        {currentStep?.id === 'review' ? (
          <StepReview key="review" form={form} />
        ) : StepComponent ? (
          <StepComponent key={currentStep!.id} form={form} />
        ) : null}

        {submitError && (
          <p className="mt-4 text-sm text-red-600" role="alert">
            {submitError}
          </p>
        )}
      </main>

      {/* Bottom navigation */}
      <footer className="border-t px-4 py-3">
        <div className="mx-auto flex max-w-xl justify-between">
          <button
            type="button"
            onClick={handleBack}
            disabled={currentStepIndex === 0 || validating || submitting}
            className="rounded px-4 py-2 text-sm disabled:opacity-50"
          >
            Back
          </button>
          <button
            type="button"
            onClick={isLastStep ? handleSubmit : handleNext}
            disabled={validating || submitting}
            className="rounded bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50"
          >
            {submitting
              ? 'Submitting…'
              : validating
              ? 'Checking…'
              : isLastStep
              ? 'Submit'
              : 'Next'}
          </button>
        </div>
      </footer>
    </div>
  )
}
```

---

## 11. Server action — `wizard.actions.ts`

The action receives the full form values, normalizes, validates, and creates records. Order matters — create in dependency order to avoid orphaned records.

```typescript
// wizard.actions.ts
'use server'

import { fullWizardSchema } from './wizard.validation'
import type { WizardServerInput } from './wizard.types'

export interface WizardActionResult {
  ok: boolean
  message?: string
  redirectUrl?: string
  fieldErrors?: Record<string, string[]>
}

// Accepts WizardServerInput (confirmPassword already stripped by the client).
export async function submitWizard(input: WizardServerInput): Promise<WizardActionResult> {
  // 1. Full validation (Zod)
  const parsed = fullWizardSchema.safeParse(input)
  if (!parsed.success) {
    const fieldErrors = parsed.error.issues.reduce<Record<string, string[]>>((acc, issue) => {
      const key = issue.path.join('.') || 'form'
      acc[key] = [...(acc[key] ?? []), issue.message]
      return acc
    }, {})
    return { ok: false, message: 'Please fix the errors below.', fieldErrors }
  }

  const data = parsed.data

  // 2. Create records in dependency order
  let accountId: string | null = null
  try {
    // Create account first
    const account = await createAccount({
      email: data.email,
      password: data.password,  // hash this in your real implementation
    })
    accountId = account.id

    // Create subscription
    await createSubscription({
      accountId: account.id,
      plan: data.plan,
      billingInterval: data.billingInterval,
    })

    // Create team only when plan requires it
    if (data.plan !== 'solo') {
      await createTeam({
        accountId: account.id,
        name: data.teamName!,
        slug: data.teamSlug!,
        members: data.teamMembers ?? [],
      })
    }

    return { ok: true, redirectUrl: '/dashboard' }
  } catch (error) {
    // Roll back the account if later steps fail — prevents orphaned records
    if (accountId) {
      await deleteAccount(accountId).catch(() => {})
    }

    // Map known DB constraint violations to field errors
    if (isSlugTakenError(error)) {
      return {
        ok: false,
        message: 'That team slug is already taken.',
        fieldErrors: { teamSlug: ['This slug is already in use. Choose a different one.'] },
      }
    }
    if (isEmailTakenError(error)) {
      return {
        ok: false,
        message: 'An account with that email already exists.',
        fieldErrors: { email: ['An account with this email already exists.'] },
      }
    }

    const message = error instanceof Error ? error.message : 'An unexpected error occurred.'
    return { ok: false, message }
  }
}

// Placeholder helpers — replace with your ORM / API calls
async function createAccount(_data: { email: string; password: string }) {
  return { id: 'acc_123' }
}
async function createSubscription(_data: { accountId: string; plan: string; billingInterval: string }) {}
async function createTeam(_data: { accountId: string; name: string; slug: string; members: unknown[] }) {}
async function deleteAccount(_id: string) {}
function isSlugTakenError(_e: unknown): boolean { return false }
function isEmailTakenError(_e: unknown): boolean { return false }
```

---

## 12. Mapping server errors back into the form

When the server action returns `fieldErrors`, apply them into TanStack Form field meta so errors appear on the right inputs — even after a full schema failure or a DB constraint violation.

```typescript
// wizard-error-mapping.ts
import type { WizardForm } from './useWizardForm'

export function applyServerErrors(
  form: WizardForm,
  fieldErrors: Record<string, string[]>,
) {
  for (const [fieldName, messages] of Object.entries(fieldErrors)) {
    form.setFieldMeta(fieldName as never, (prev) => ({
      ...prev,
      isTouched: true,
      errorMap: {
        ...prev?.errorMap,
        onSubmit: messages.map((message) => ({ message })),
      },
    }))
  }
}
```

This is the same mechanism used for per-step validation — field errors are always written into `errorMap.onSubmit` so that `<FieldError errors={field.state.meta.errors} />` picks them up automatically.

---

## 13. Advanced: array steps

When a step manages a dynamic list, use TanStack Form's array helpers. This is an extension of `StepTeam` that lets the user add team members.

```tsx
// steps/StepTeam.tsx
'use client'

import type { WizardForm } from '../useWizardForm'
import { Field, FieldLabel, FieldError } from '@/components/ui/field'
import { Input } from '@/components/ui/input'

function createDefaultMember() {
  return { id: `member-${Date.now()}`, email: '', role: 'member' as const }
}

export function StepTeam({ form }: { form: WizardForm }) {
  return (
    <div className="space-y-6">
      <form.Field name="teamName">
        {(field) => (
          <Field>
            <FieldLabel htmlFor={field.name}>Team name</FieldLabel>
            <Input
              id={field.name}
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
            />
            <FieldError errors={field.state.meta.errors} />
          </Field>
        )}
      </form.Field>

      <form.Field name="teamSlug">
        {(field) => (
          <Field>
            <FieldLabel htmlFor={field.name}>Team URL slug</FieldLabel>
            <Input
              id={field.name}
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
              placeholder="my-team"
            />
            <FieldError errors={field.state.meta.errors} />
          </Field>
        )}
      </form.Field>

      {/* Team members */}
      <div className="space-y-3">
        <p className="text-sm font-medium">Team members</p>
        <form.Field name="teamMembers" mode="array">
          {(field) => (
            <>
              {field.state.value.map((_, i) => (
                <div key={field.state.value[i]?.id ?? i} className="flex gap-2">
                  <form.Field name={`teamMembers[${i}].email`}>
                    {(memberField) => (
                      <Input
                        type="email"
                        value={memberField.state.value}
                        onBlur={memberField.handleBlur}
                        onChange={(e) => memberField.handleChange(e.target.value)}
                        placeholder="colleague@company.com"
                        className="flex-1"
                      />
                    )}
                  </form.Field>
                  <button
                    type="button"
                    onClick={() => field.removeValue(i)}
                    className="text-sm text-red-600"
                  >
                    Remove
                  </button>
                </div>
              ))}
              <button
                type="button"
                onClick={() => field.pushValue(createDefaultMember())}
                className="text-sm underline"
              >
                + Add member
              </button>
            </>
          )}
        </form.Field>
      </div>
    </div>
  )
}
```

**Why arrays, not records:** TanStack Form v1 requires field names known at compile time. Dynamic keys like `` `members.${id}` `` do not type-check. Arrays of `{ id, value }` objects avoid this.

---

## 14. Advanced: credential sidecar

If you want to persist a password hint across sessions (so users know they already set a password), hash it server-side with HMAC-SHA256 and store only the hash. The hash is one-way — it cannot be used to log in.

```typescript
// credential-sidecar.actions.ts
'use server'
import crypto from 'node:crypto'

export async function hashPassword(password: string): Promise<string | null> {
  const secret = process.env.WIZARD_CREDENTIAL_SECRET
  if (!secret) return null  // silently skip if env var is absent
  return crypto.createHmac('sha256', secret).update(password).digest('hex')
}
```

```typescript
// credential-persist.ts (client)
const CREDENTIAL_KEY = 'my-wizard:credentials:v1'

export function saveCredentialHash(email: string, passwordHash: string) {
  try { localStorage.setItem(CREDENTIAL_KEY, JSON.stringify({ email, passwordHash })) } catch {}
}

export function clearCredentialHash() {
  try { localStorage.removeItem(CREDENTIAL_KEY) } catch {}
}
```

Call `hashPassword` and `saveCredentialHash` after the account step passes validation. Call `clearCredentialHash` after a successful submit alongside `clearWizardDraft`.

Add `WIZARD_CREDENTIAL_SECRET` to your environment. Its absence is safe — the sidecar is silently skipped.

---

## 15. Advanced: dev debug panel

During development, render a panel below the wizard that shows the live form state and lets you paste JSON to set the form to any state. Gate it with `process.env.NODE_ENV === 'development'`.

```tsx
// DevDebugPanel.tsx
'use client'
import { useState } from 'react'
import { useStore } from '@tanstack/react-form'
import type { WizardForm } from './useWizardForm'

export function DevDebugPanel({ form }: { form: WizardForm }) {
  const state = useStore(form.store, (s) => s)
  const [editor, setEditor] = useState(() => JSON.stringify(state, null, 2))
  const [error, setError] = useState<string | null>(null)

  const apply = () => {
    try {
      const parsed = JSON.parse(editor)
      const values = (parsed?.values ?? parsed) as Record<string, unknown>
      for (const [key, value] of Object.entries(values)) {
        form.setFieldValue(key as never, value)
      }
      setEditor(JSON.stringify({ ...state, values }, null, 2))
      setError(null)
    } catch (e) {
      setError(e instanceof Error ? e.message : 'Invalid JSON')
    }
  }

  return (
    <details className="border-t bg-slate-950 px-4 py-4 text-slate-50">
      <summary className="cursor-pointer text-sm font-semibold">Dev — form state</summary>
      <div className="mt-3 grid gap-4 lg:grid-cols-2">
        <pre className="max-h-96 overflow-auto text-xs">{JSON.stringify(state, null, 2)}</pre>
        <div className="space-y-2">
          <textarea
            value={editor}
            onChange={(e) => setEditor(e.target.value)}
            spellCheck={false}
            className="min-h-64 w-full rounded border bg-slate-900 p-2 font-mono text-xs"
          />
          {error && <p className="text-xs text-rose-400">{error}</p>}
          <button
            type="button"
            onClick={apply}
            className="rounded border border-emerald-500 px-3 py-1 text-xs text-emerald-300"
          >
            Apply to form
          </button>
        </div>
      </div>
    </details>
  )
}
```

Usage in `WizardBody`:
```tsx
{process.env.NODE_ENV === 'development' && <DevDebugPanel form={form} />}
```

---

## 16. Adding a new step — checklist

1. Add the step ID to the `StepId` union in `wizard-step-config.ts`
2. Add a `StepConfig` entry to `STEP_CONFIGS` with a `visible` predicate
3. Write a Zod schema for the step in `wizard.validation.ts`
4. Add the schema to `stepValidators` in `useWizardStep.ts`
5. Add the step's field name prefixes to `stepFieldPrefixes`
6. Add default values to `defaultWizardValues` in `useWizardForm.ts`
7. Add the new fields to `WizardFormValues` in `wizard.types.ts`
8. Create the step component in `steps/StepNewThing.tsx`
9. Add the component to `STEP_COMPONENTS` in `WizardShell.tsx`
10. Add publish logic for the new data to `submitWizard` in `wizard.actions.ts`

---

## 17. Common pitfalls

### Plaintext passwords in localStorage
`saveWizardDraft` stores `params.values` — if you pass the raw form values without stripping passwords, `password` and `confirmPassword` land in `localStorage`. Always call `sanitizeDraftValues` before building the envelope. `mergePersistedValues` must also explicitly reset passwords to empty strings.

### Hydration flash
Using `useEffect` instead of `useLayoutEffect` for draft loading causes the form to render with empty defaults for one frame before the draft is applied. `useLayoutEffect` fires before paint, so the form has the correct state on the first render.

### Dynamic field keys in TanStack Form v1
Field names like `` `stock.${id}` `` do not type-check because TanStack Form v1 requires compile-time field names. Use arrays of `{ id, value }` objects instead of `Record<string, value>`.

### Step index not clamping
When steps disappear (user changes a choice that removes a conditional step), `currentStepIndex` can be out of range for the new visible-steps array. The `useEffect` in `useWizardStep` handles this — do not remove it.

### Stale errors leaking across steps
Without calling `clearStepErrors` before `applyStepErrors`, errors from a previous visit to a step persist when you return to it. The validation flow in `useWizardStep.validateCurrentStep` always clears first.

### Forgetting to clear the draft on success
If `clearWizardDraft()` is not called after submit, the user who returns to the wizard URL is dropped back into the completed wizard. Always clear draft and credential sidecar on success.

### `confirmPassword` must be stripped before the server call
`confirmPassword` is validated client-side (must match `password`) but must not be stored in the draft or processed by the server. Two safeguards are required: `sanitizeDraftValues` strips it before `localStorage`, and the shell destructures it out before calling `submitWizard`. The server action accepts `WizardServerInput` (which omits `confirmPassword`), and `fullWizardSchema` does not include it. If you skip either strip, TypeScript will catch the mismatch.

### Server `fieldErrors` not shown on inputs
Returning `fieldErrors` from the server action is not enough — you must call `applyServerErrors(form, result.fieldErrors)` to write them into TanStack Form field meta. Without this, DB errors like "slug already taken" only appear in the generic `submitError` message, not on the relevant input.

### Unique constraint errors are not Zod errors
Zod validates the shape of the input, not uniqueness. "Slug already taken" passes Zod and fails at the DB layer. Catch these explicitly in the server action and return them as `fieldErrors` with the relevant field name.

## LLM Response Snippet
```json
{
  "goal": "TanStack Form wizard for Next.js: step-by-step guide with Zod validation, autosave drafts, conditional steps, and single-server submit—start implementing…",
  "responses": [
    {
      "question": "What does the article \"Build a Multi-Step TanStack Form Wizard in Next.js\" cover?",
      "answer": "TanStack Form wizard for Next.js: step-by-step guide with Zod validation, autosave drafts, conditional steps, and single-server submit—start implementing…"
    }
  ]
}
```