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…
·Updated on:··
⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
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:
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.!
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:
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.
// wizard.types.tsexporttypePlan = 'solo' | 'team' | 'enterprise'exporttypeBillingInterval = 'monthly' | 'annual'exportinterfaceTeamMember {
id: string// client-side key for React renderingemail: stringrole: 'admin' | 'member'
}
exportinterfaceWizardFormValues {
// Step: planplan: Plan// Step: accountemail: stringpassword: stringconfirmPassword: string// never saved to localStorage; stripped before the server call (see §11)// Step: billingbillingInterval: BillingInterval// Step: team (conditional — only when plan !== 'solo')teamName: stringteamSlug: stringteamMembers: TeamMember[]
}
// Sent to the server — confirmPassword is stripped before the call.// The server never needs to see it: match validation is client-side only.exporttypeWizardServerInput = 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.
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.
// wizard.validation.tsimport { z } from'zod'// ── Per-step schemas ─────────────────────────────────────────────────────exportconst stepPlanSchema = z.object({
plan: z.enum(['solo', 'team', 'enterprise'], {
errorMap: () => ({ message: 'Please select a plan.' }),
}),
})
exportconst 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'],
})
exportconst stepBillingSchema = z.object({
billingInterval: z.enum(['monthly', 'annual'], {
errorMap: () => ({ message: 'Please select a billing interval.' }),
}),
})
exportconst 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.exportconst 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 soloif (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.
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.
The action receives the full form values, normalizes, validates, and creates records. Order matters — create in dependency order to avoid orphaned records.
// wizard.actions.ts'use server'import { fullWizardSchema } from'./wizard.validation'importtype { WizardServerInput } from'./wizard.types'exportinterfaceWizardActionResult {
ok: booleanmessage?: stringredirectUrl?: stringfieldErrors?: Record<string, string[]>
}
// Accepts WizardServerInput (confirmPassword already stripped by the client).exportasyncfunctionsubmitWizard(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 orderletaccountId: string | null = nulltry {
// Create account firstconst account = awaitcreateAccount({
email: data.email,
password: data.password, // hash this in your real implementation
})
accountId = account.id// Create subscriptionawaitcreateSubscription({
accountId: account.id,
plan: data.plan,
billingInterval: data.billingInterval,
})
// Create team only when plan requires itif (data.plan !== 'solo') {
awaitcreateTeam({
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 recordsif (accountId) {
awaitdeleteAccount(accountId).catch(() => {})
}
// Map known DB constraint violations to field errorsif (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 instanceofError ? error.message : 'An unexpected error occurred.'return { ok: false, message }
}
}
// Placeholder helpers — replace with your ORM / API callsasyncfunctioncreateAccount(_data: { email: string; password: string }) {
return { id: 'acc_123' }
}
asyncfunctioncreateSubscription(_data: { accountId: string; plan: string; billingInterval: string }) {}
asyncfunctioncreateTeam(_data: { accountId: string; name: string; slug: string; members: unknown[] }) {}
asyncfunctiondeleteAccount(_id: string) {}
functionisSlugTakenError(_e: unknown): boolean { returnfalse }
functionisEmailTakenError(_e: unknown): boolean { returnfalse }
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.
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.
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.
// credential-sidecar.actions.ts'use server'import crypto from'node:crypto'exportasyncfunctionhashPassword(password: string): Promise<string | null> {
const secret = process.env.WIZARD_CREDENTIAL_SECRETif (!secret) returnnull// silently skip if env var is absentreturn crypto.createHmac('sha256', secret).update(password).digest('hex')
}
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'.
Add the step ID to the StepId union in wizard-step-config.ts
Add a StepConfig entry to STEP_CONFIGS with a visible predicate
Write a Zod schema for the step in wizard.validation.ts
Add the schema to stepValidators in useWizardStep.ts
Add the step's field name prefixes to stepFieldPrefixes
Add default values to defaultWizardValues in useWizardForm.ts
Add the new fields to WizardFormValues in wizard.types.ts
Create the step component in steps/StepNewThing.tsx
Add the component to STEP_COMPONENTS in WizardShell.tsx
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.
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.