In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
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.
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:
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.
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.
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.
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.tsimport { z } from'zod'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((values) => values.password === values.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.'),
})
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) => {
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.
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.
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.
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.
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:
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.
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.
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.
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.
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.
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.
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.
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.
Add the new ID to StepId in wizard-step-config.ts.
Add a StepConfig entry and visibility rule to STEP_CONFIGS.
Extend WizardFormValues in wizard.types.ts.
Add default values in useWizardForm.ts.
Write the step schema in wizard.validation.ts.
Register the schema and field prefixes in useWizardStep.ts.
Create the step component under steps/.
Register the component in WizardShell.tsx.
Extend fullWizardSchema if the server must validate the new fields.
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.
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.