BuildWithMatija
  1. Home
  2. Blog
  3. Next.js
  4. Build a Multi-Step TanStack Form Wizard in Next.js

Build a Multi-Step TanStack Form Wizard in Next.js

Production-ready Next.js pattern with TanStack Form and Zod — autosave drafts, conditional steps, and per-step…

24th May 2026·Updated on:3rd June 2026··
Next.js
Build a Multi-Step TanStack Form Wizard in Next.js

⚡ 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

  • What this guide covers
  • 1. Requirements and assumptions
  • 2. Folder structure
  • 3. Shared types — `wizard.types.ts`
  • 4. Step configuration — `wizard-step-config.ts`
  • 5. Validation — `wizard.validation.ts`
  • 6. The shared form hook — `useWizardForm.ts`
  • 7. Step navigation and per-step validation — `useWizardStep.ts`
  • 8. Step components
  • 9. Draft persistence — `wizard-persist.ts`
  • 10. The shell component — `WizardShell.tsx`
  • 11. The server action — `wizard.actions.ts`
  • 12. Mapping server errors back into the form
  • 13. Advanced: array steps
  • 14. Advanced: credential sidecar
  • 15. Advanced: dev debug panel
  • 16. Adding a new step
  • 17. Common pitfalls
  • Conclusion
On this page:
  • What this guide covers
  • 1. Requirements and assumptions
  • 2. Folder structure
  • 3. Shared types — `wizard.types.ts`
  • 4. Step configuration — `wizard-step-config.ts`
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

Moving from a basic multi-step form to a production-ready wizard in Next.js is not straightforward. I recently had to build an onboarding flow that needed conditional steps, safe draft persistence, and one final server-side submit, and most examples covered only one piece of that journey at a time. This guide walks through the full implementation I landed on so you can build the same pattern without stitching together half-finished snippets.

By the end, you will have a wizard that:

  • keeps all state in one TanStack Form instance
  • validates one step at a time with Zod
  • shows and hides steps based on earlier answers
  • autosaves safe draft data to localStorage
  • submits everything through one server action at the end

The example flow is: plan -> account -> billing -> team (conditional) -> review. The team step appears only for multi-user plans, but the pattern works for any branching onboarding or checkout flow.

What this guide covers

  1. Requirements and assumptions
  2. Folder structure
  3. Shared types
  4. Step configuration
  5. Validation
  6. The shared form hook
  7. Step navigation and per-step validation
  8. Step components
  9. Draft persistence
  10. The shell component
  11. The server action
  12. Mapping server errors back into the form
  13. Advanced: array steps
  14. Advanced: credential sidecar
  15. Advanced: dev debug panel
  16. Adding a new step
  17. Common pitfalls

1. Requirements and assumptions

Before we build any wizard logic, we need a clear baseline. This step keeps the rest of the guide focused on the pattern itself rather than framework setup noise.

bash
pnpm add @tanstack/react-form zod lucide-react

Assume the following:

  • Next.js App Router with Server Actions enabled
  • TypeScript, ideally with strict mode
  • client components for the wizard UI
  • thin form UI primitives such as Field, FieldLabel, FieldError, and Input

If you do not already have those primitives, this minimal version is enough:

tsx
// File: 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>
}

This code gives the wizard a small, reusable surface for labels and errors without tying the pattern to any one component library. That choice matters because the interesting part of this guide is the form architecture, not the styling layer. With the UI baseline in place, we can set up a file structure that keeps the rest of the implementation easy to reason about.

2. Folder structure

Once the dependencies are ready, the next job is separating responsibilities. Multi-step forms get messy fast when types, validation, navigation, persistence, and rendering all live in one file.

text
src/wizard/
├── wizard.types.ts
├── wizard-step-config.ts
├── wizard.validation.ts
├── useWizardForm.ts
├── useWizardStep.ts
├── wizard-persist.ts
├── wizard.actions.ts
├── wizard-error-mapping.ts
├── WizardShell.tsx
└── steps/
    ├── StepPlan.tsx
    ├── StepAccount.tsx
    ├── StepBilling.tsx
    ├── StepTeam.tsx
    └── StepReview.tsx

This structure keeps each part of the journey in one place: data shape, step visibility, validation, runtime behavior, and UI. I prefer this split because conditional steps and autosave are much easier to maintain when they are modeled as separate concerns instead of hidden inside one giant component. Now that the folders are clear, we can define the shared form shape every other file depends on.

3. Shared types — wizard.types.ts

The form shape is the foundation of the entire wizard. If the data contract is not centralized, every later section ends up duplicating field names and drifting out of sync.

typescript
// File: src/wizard/wizard.types.ts
export type Plan = 'solo' | 'team' | 'enterprise'
export type BillingInterval = 'monthly' | 'annual'

export interface TeamMember {
  id: string
  email: string
  role: 'admin' | 'member'
}

export interface WizardFormValues {
  plan: Plan
  email: string
  password: string
  confirmPassword: string
  billingInterval: BillingInterval
  teamName: string
  teamSlug: string
  teamMembers: TeamMember[]
}

export type WizardServerInput = Omit<WizardFormValues, 'confirmPassword'>

This file defines the entire wizard in one place, including fields that only appear conditionally like teamName and teamMembers. I chose a single full-form type rather than separate per-step types because TanStack Form works best when the whole form state lives in one store. That gives every later layer, from validation to draft saving to final submit, one consistent contract to work with. With the data shape locked in, the next step is deciding which screens exist and when they should appear.

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

At this point we know what data the wizard holds, but not how the reader moves through it. Step configuration is where the form stops being a static sequence and becomes a real conditional journey.

typescript
// File: src/wizard/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: (values) => values.plan === 'team' || values.plan === 'enterprise',
  },
  { id: 'review', always: true, visible: () => true },
]

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

This configuration file creates one source of truth for the wizard path. The important idea is that visibleSteps is derived from current form values, not hardcoded indices, so if someone changes from team back to solo, the team step simply drops out of the journey. That keeps the navigation logic honest and prevents hidden stale steps from lingering in state. Now that the route through the wizard is defined, we can add the rules that decide whether a user may continue.

5. Validation — wizard.validation.ts

The next step is separating validation into two layers: one for moving between steps and one for trusting data on the server. That split is what makes the wizard feel responsive without weakening final submit safety.

typescript
// File: src/wizard/wizard.validation.ts
import { z } from 'zod'

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((values) => values.password === values.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.'),
})

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) => {
    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.' })
      }
    }
  })

These schemas let the wizard validate just the active step during navigation while still enforcing the complete shape during final submission. I chose this split because it keeps the flow smooth for the user and still gives the server the last word. The key concept here is that step schemas own local progress, while the full schema owns final trust. With the rules written down, we can create the shared form instance that all steps will read from.

6. The shared form hook — useWizardForm.ts

Now we can instantiate the form itself. This hook is deliberately small because its job is not to manage wizard behavior; it just creates the single form store that the rest of the architecture builds on.

typescript
// File: src/wizard/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 })
}

export type WizardForm = ReturnType<typeof useWizardForm>

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

This hook creates one TanStack Form instance for the whole journey instead of one form per screen. That decision simplifies everything that follows: conditional steps can read prior answers, autosave can persist one object, and the final submit can send one normalized payload. With the shared store ready, the next step is teaching the wizard how to move through visible steps and validate them one at a time.

7. Step navigation and per-step validation — useWizardStep.ts

This is the control layer of the wizard. It combines the step configuration and the validation rules so the user can move forward, move backward, and recover cleanly when the path changes.

typescript
// File: src/wizard/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'

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

const stepFieldPrefixes: Record<Exclude<StepId, 'review'>, string[]> = {
  plan: ['plan'],
  account: ['email', 'password', 'confirmPassword'],
  billing: ['billingInterval'],
  team: ['teamName', 'teamSlug', 'teamMembers'],
}

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((prefix) => matchesFieldPrefix(fieldName, prefix))) 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 })),
      },
    }))
  }

  if (!Object.keys(errors).includes(rootField)) {
    form.setFieldMeta(rootField as never, (prev) => ({
      ...prev,
      isTouched: true,
    }))
  }
}

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 ?? []),
  )

  const visibleSteps = useMemo(() => getVisibleSteps(formValues), [formValues])

  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((step) => step.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

  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],
  )

  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((step) => step.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,
  }
}

This hook is where the wizard starts behaving like a product instead of a collection of inputs. It recalculates visible steps from live values, clears stale errors step-by-step, and prevents navigation logic from depending on fixed positions that can go stale. I chose to write errors into errorMap.onSubmit so both client and server validation can share the same display path. With the movement layer in place, we can finally render the actual step screens.

8. Step components

The UI layer should stay boring. Each step simply reads from the shared form and renders the fields it owns, leaving validation and navigation to the hooks we already built.

Here is the account step:

tsx
// File: src/wizard/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>
  )
}

This component is intentionally simple: render the fields, bind them to TanStack Form, and let shared form state handle the rest. That is why this approach scales well across steps. The component does not need to know about wizard progress, conditional logic, or persistence. It only needs to know which inputs belong to this step.

The remaining steps follow the same pattern:

tsx
// File: src/wizard/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>
  )
}
tsx
// File: src/wizard/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>
  )
}
tsx
// File: src/wizard/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, (state) => state.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((member) => member.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>
  )
}

These components show the overall pattern: render with form.Field, keep step logic local, and read summary data from the shared store on the review screen. I prefer this because each screen stays easy to replace without touching validation or persistence. A useful concept to keep in mind is that the UI is the thinnest layer in this architecture. The next step is making sure users do not lose progress if they refresh midway through the journey.

Two small implementation details are worth keeping in mind:

tsx
<form.Subscribe selector={(state) => state.values.plan}>
  {(plan) =>
    plan === 'enterprise' && (
      <p className="text-sm text-muted-foreground">
        Enterprise billing is invoiced annually.
      </p>
    )
  }
</form.Subscribe>
tsx
<Select
  value={field.state.value}
  onValueChange={(value) => field.handleChange(value as typeof field.state.value)}
>

form.Subscribe lets you watch only the values that matter, and explicit casting is often needed when UI libraries emit plain string values for union-typed fields. With the UI in place, we can now make the wizard resilient across reloads.

9. Draft persistence — wizard-persist.ts

Draft persistence is the step that makes the wizard feel production-ready, but it is also where it is easiest to accidentally leak sensitive data. The goal here is to save progress without ever writing credentials to localStorage.

typescript
// File: src/wizard/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'>>
}

function sanitizeDraftValues(
  values: WizardFormValues,
): Partial<Omit<WizardFormValues, 'password' | 'confirmPassword'>> {
  const { password: _password, confirmPassword: _confirmPassword, ...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 {
  }
}

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 {
  }
}

export function mergePersistedValues(
  partial: Partial<WizardFormValues> | undefined,
  defaults: WizardFormValues,
): WizardFormValues {
  return {
    ...defaults,
    ...partial,
    password: '',
    confirmPassword: '',
  }
}

This module persists only safe values, tracks the current step, and restores the wizard in a versioned envelope. I chose this shape so future schema changes can invalidate old drafts cleanly instead of breaking hydration in subtle ways. The key concept is that draft persistence should be permissive for in-progress values but strict about excluding secrets. With safe persistence handled, the next step is composing the actual wizard shell that uses everything we have built so far.

10. The shell component — WizardShell.tsx

This is where the journey comes together. The shell hydrates initial values, restores draft state, wires autosave, renders the current step, and decides when to move forward or submit.

tsx
// File: src/wizard/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'
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,
}

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(() => {
    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} />
}

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, (state) => state.values)

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

  const snapshotRef = useRef<{
    form: WizardForm
    currentStepIndex: number
    completedStepIds: StepId[]
  } | null>(null)

  snapshotRef.current = {
    form,
    currentStepIndex,
    completedStepIds: Array.from(completedSteps),
  }

  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])

  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])

  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()
  }

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

    if (currentStep && currentStep.id !== 'review') {
      setValidating(true)
      try {
        const valid = await validateCurrentStep(form)
        if (!valid) return
      } finally {
        setValidating(false)
      }
    }

    setSubmitting(true)
    try {
      const { confirmPassword: _confirmPassword, ...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
      }

      if (result.fieldErrors) {
        applyServerErrors(form, result.fieldErrors)
      }
      setSubmitError(result.message ?? 'Submission failed. Please try again.')
    } finally {
      setSubmitting(false)
    }
  }

  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">
      <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>

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

      <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>
  )
}

This shell is the assembly point for the whole implementation. It restores draft values before paint, saves progress during the session, validates before navigation, and switches from step-by-step movement to one final server submit. I chose to keep this orchestration in a single shell because it gives you one place to understand the runtime flow from first render to completion. Now that the client side is complete, the next step is handling the server-side end of the journey.

11. The server action — wizard.actions.ts

The browser should never be the final authority on data quality. The server action closes the loop by validating the full payload, creating records in dependency order, and translating known failures into field errors the UI can understand.

typescript
// File: src/wizard/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[]>
}

export async function submitWizard(input: WizardServerInput): Promise<WizardActionResult> {
  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

  let accountId: string | null = null
  try {
    const account = await createAccount({
      email: data.email,
      password: data.password,
    })
    accountId = account.id

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

    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) {
    if (accountId) {
      await deleteAccount(accountId).catch(() => {})
    }

    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 }
  }
}

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(_error: unknown): boolean {
  return false
}

function isEmailTakenError(_error: unknown): boolean {
  return false
}

This action accepts the full wizard payload, validates it again on the server, and creates dependent records in the correct order. That ordering matters because partial success can otherwise leave orphaned data behind. The important concept here is that the server action does not just submit data; it translates domain failures into something the wizard can present inline. To make that translation work cleanly, we need one small mapping helper on the client.

12. Mapping server errors back into the form

Once the server returns fieldErrors, we want those messages to appear in the same place as step-level validation errors. That keeps the user experience consistent even when the failure comes from a database rule rather than a Zod schema.

typescript
// File: src/wizard/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 helper writes server messages into the same errorMap.onSubmit path used by step validation, so the field components do not need a second rendering path. I chose this approach because consistency at the metadata layer keeps the UI dead simple. The core wizard is now complete, which means the rest of the guide can focus on extensions that build on the same architecture.

13. Advanced: array steps

The first extension is dynamic list data. If your wizard needs a variable number of repeated inputs, such as team members, the same pattern still works as long as you model the collection as an array.

tsx
// File: src/wizard/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>

      <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>
  )
}

This version of the team step shows how array helpers fit naturally into the same wizard shell and validation system. I chose arrays over record-like dynamic keys because TanStack Form v1 types arrays far more cleanly when fields are created at runtime. The key idea is that advanced inputs should still conform to the same shared form contract, not invent a parallel state system.

14. Advanced: credential sidecar

Sometimes you want to preserve a hint that a password was already set without ever persisting the raw password. If that is useful in your flow, add a one-way sidecar hash rather than storing any credential data directly.

typescript
// File: src/wizard/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
  return crypto.createHmac('sha256', secret).update(password).digest('hex')
}
typescript
// File: src/wizard/credential-persist.ts
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 {
  }
}

This approach keeps the main draft store clean while still allowing a separate signal that credentials were previously captured. I chose an HMAC-based hash because it is one-way and environment-backed, which is far safer than storing anything reusable in the browser. If you do not need this behavior, skip it. If you do, it plugs into the same account step flow without changing the wizard architecture. Another practical extension during development is exposing live form state so you can jump around the journey quickly.

15. Advanced: dev debug panel

Once a wizard has conditional logic, reload state, and server mapping, manual clicking gets slow. A debug panel makes it easy to inspect or patch state while you are developing the flow.

tsx
// File: src/wizard/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, (store) => store)
  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 inside WizardBody:

tsx
{process.env.NODE_ENV === 'development' && <DevDebugPanel form={form} />}

This panel exposes the full form state and lets you paste JSON back into the store during development. I like this approach because it shortens the feedback loop when working on branching step logic or draft restoration. It is still part of the same journey: the more involved the wizard becomes, the more useful visibility into shared state becomes. If you later add another screen, the process stays predictable.

16. Adding a new step

By this point the architecture is stable enough that adding a step should feel procedural rather than risky. The checklist below follows the same sequence we used in the main implementation.

  1. Add the new ID to StepId in wizard-step-config.ts.
  2. Add a StepConfig entry and visibility rule to STEP_CONFIGS.
  3. Extend WizardFormValues in wizard.types.ts.
  4. Add default values in useWizardForm.ts.
  5. Write the step schema in wizard.validation.ts.
  6. Register the schema and field prefixes in useWizardStep.ts.
  7. Create the step component under steps/.
  8. Register the component in WizardShell.tsx.
  9. Extend fullWizardSchema if the server must validate the new fields.
  10. Update submitWizard to persist the new data.

This list works because every part of the wizard has a dedicated home: type, visibility, validation, rendering, and submission. That separation is what turns the pattern into something you can keep extending instead of rewriting from scratch. Before wrapping up, it is worth calling out the mistake that causes the most trouble in implementations like this.

17. Common pitfalls

The easiest way to break an otherwise solid wizard is to treat draft persistence casually. If you save raw form values, you risk writing password and confirmPassword into localStorage, which is exactly what this pattern is designed to avoid.

Always sanitize before saving:

typescript
function sanitizeDraftValues(values: WizardFormValues) {
  const { password: _password, confirmPassword: _confirmPassword, ...safeValues } = values
  return safeValues
}

This helper is small, but it protects the whole experience by keeping credential fields out of browser storage. That is also why mergePersistedValues restores passwords as empty strings every time. If you keep that boundary in place, the rest of the wizard architecture remains straightforward.

Conclusion

We started with a common problem: building a multi-step form in Next.js that feels polished once you add branching logic, validation, draft persistence, and a final server submit. The solution was to treat the wizard as one shared TanStack Form instance, then layer step visibility, per-step Zod validation, safe persistence, and server-side error mapping on top of that single source of truth.

You can now build a guided onboarding flow that keeps its state coherent, survives refreshes, validates the right fields at the right time, and submits through one trusted backend entry point. Let me know in the comments if you have questions, and subscribe for more practical development guides.