# What You'll Build#
A multi-step wizard in Next.js App Router that posts each step via Server Actions, validates with Zod, and persists draft state without adding API routes.
You’ll see 3 implementation approaches, how to choose between them, and how to ship production basics like optimistic UX, error handling, and accessibility.
If you want to deepen the fundamentals first, read:
# Why Server Actions for Multi‑Step Forms#
Server Actions remove the “extra API layer” that usually duplicates logic across routes, controllers, and validators. You can validate and persist on the server, then redirect to the next step in a single flow.
This matters because multi-step wizards have more failure points than single-page forms: step-to-step navigation, partial persistence, back button behavior, and concurrency. Centralizing validation in server actions reduces drift and simplifies audits.
ℹ️ Note: Server Actions are not “free performance.” Each step submit is a round-trip. The win is correctness and simplicity: the server becomes the single source of truth for validation and persistence.
# Three State Strategies (and When to Use Each)#
The main decision is where your draft lives between steps. Below is a practical comparison you can use in scoping.
| Strategy | Where draft lives | Best for | Pros | Cons | Avoid when |
|---|---|---|---|---|---|
| Cookies or session | Encrypted cookie or server session | Short wizards, low-risk data | Fast setup, no DB required, easy redirects | Cookie size limits, sensitive data risk, harder multi-device | Payment info, long drafts, large payloads |
| DB draft | Database row keyed by draftId | Long wizards, logged-in users, multi-device | Durable, auditable, supports resume | Needs DB, cleanup/TTL, more logic | Tiny forms where DB is overkill |
| URL state | Query params per step | Filters, plan selections, non-sensitive choices | Shareable URLs, great back button behavior | Exposes data, URL length limits | Anything sensitive, large text fields |
A good rule: if the user could reasonably resume later, use a DB draft. If it’s a 2 to 4 step quote form and data is not sensitive, cookie draft can be enough. If it’s just a “choose plan, choose add-ons” flow, URL state is simplest.
# Data Model and Zod Schemas (Step-Level and Full Validation)#
Design schemas first. Multi-step flows tend to accumulate “optional now, required later” fields, so you need two layers:
- 1Per-step schema to validate what the user just submitted.
- 2Full schema to validate everything before final submission.
Example Wizard: Project Inquiry#
Steps:
- 1Contact
- 2Project details
- 3Budget and review
We’ll represent the draft as a single object.
// lib/schemas/projectInquiry.ts
import { z } from "zod";
export const ContactStepSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Enter a valid email"),
});
export const DetailsStepSchema = z.object({
projectType: z.enum(["web", "mobile", "automation"]),
timeline: z.enum(["asap", "1-3m", "3-6m", "6m+"]),
notes: z.string().max(1000, "Notes must be 1000 chars or less").optional(),
});
export const BudgetStepSchema = z.object({
budgetEur: z.coerce.number().int().min(1000, "Budget must be at least 1000 EUR"),
consent: z.coerce.boolean().refine((v) => v === true, "Consent is required"),
});
export const FullInquirySchema = ContactStepSchema
.merge(DetailsStepSchema)
.merge(BudgetStepSchema);
export type InquiryDraft = z.infer<typeof FullInquirySchema>;Error Shape for Form Rendering#
Server actions should return a predictable structure for field errors. Avoid throwing for expected validation errors.
// lib/forms/errors.ts
export type FieldErrors = Record<string, string | undefined>;
export type ActionState<TFields extends FieldErrors = FieldErrors> = {
ok: boolean;
message?: string;
fieldErrors?: TFields;
};# Approach 1: Cookies Draft (Fastest Setup)#
This is the fastest way to ship a wizard when:
- The draft is small.
- Data is not sensitive.
- You don’t need multi-device resume.
- You can tolerate clearing draft when cookies clear.
How It Works#
- Each step action validates input.
- If valid, merge into a draft object.
- Store draft in a cookie.
- Redirect to next step.
Cookie Helpers#
Keep cookies small. Realistically, you have a few kilobytes. Avoid large free-text fields.
// lib/draft/cookieDraft.ts
"use server";
import { cookies } from "next/headers";
const DRAFT_COOKIE = "inquiry_draft_v1";
export function getDraftFromCookie(): Record<string, unknown> {
const raw = cookies().get(DRAFT_COOKIE)?.value;
if (!raw) return {};
try {
return JSON.parse(raw);
} catch {
return {};
}
}
export function setDraftCookie(draft: Record<string, unknown>) {
const value = JSON.stringify(draft);
cookies().set(DRAFT_COOKIE, value, {
httpOnly: true,
sameSite: "lax",
secure: true,
path: "/wizard",
maxAge: 60 * 60,
});
}
export function clearDraftCookie() {
cookies().delete(DRAFT_COOKIE);
}⚠️ Warning: Do not store secrets or regulated personal data in cookies unless you are encrypting and you understand your compliance requirements. Even with httpOnly, cookies are still sent on every request, which increases exposure and can bloat headers.
Step Action Example (Contact)#
Return validation errors as state, redirect on success.
// app/wizard/contact/actions.ts
"use server";
import { redirect } from "next/navigation";
import { ContactStepSchema } from "@/lib/schemas/projectInquiry";
import { getDraftFromCookie, setDraftCookie } from "@/lib/draft/cookieDraft";
import type { ActionState } from "@/lib/forms/errors";
export async function submitContact(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const input = {
name: String(formData.get("name") ?? ""),
email: String(formData.get("email") ?? ""),
};
const parsed = ContactStepSchema.safeParse(input);
if (!parsed.success) {
const fieldErrors = parsed.error.flatten().fieldErrors;
return {
ok: false,
fieldErrors: {
name: fieldErrors.name?.[0],
email: fieldErrors.email?.[0],
},
message: "Please fix the errors and try again.",
};
}
const existing = getDraftFromCookie();
setDraftCookie({ ...existing, ...parsed.data });
redirect("/wizard/details");
}Accessible Step Page with Optimistic UX#
Use useActionState for error display and useFormStatus for pending UI. Disable submit during pending to prevent duplicate submissions.
// app/wizard/contact/page.tsx
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { submitContact } from "./actions";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Saving..." : "Next"}
</button>
);
}
export default function ContactStep() {
const [state, action] = useActionState(submitContact, { ok: true });
return (
<main>
<h1>Step 1 of 3: Contact</h1>
{state.message && !state.ok ? (
<p role="alert">{state.message}</p>
) : null}
<form action={action} noValidate>
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
aria-invalid={state.fieldErrors?.name ? "true" : "false"}
aria-describedby={state.fieldErrors?.name ? "name-error" : undefined}
/>
{state.fieldErrors?.name ? (
<p id="name-error" role="alert">
{state.fieldErrors.name}
</p>
) : null}
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
inputMode="email"
aria-invalid={state.fieldErrors?.email ? "true" : "false"}
aria-describedby={state.fieldErrors?.email ? "email-error" : undefined}
/>
{state.fieldErrors?.email ? (
<p id="email-error" role="alert">
{state.fieldErrors.email}
</p>
) : null}
<div style={{ display: "flex", gap: 12 }}>
<a href="/">Cancel</a>
<SubmitButton />
</div>
</form>
</main>
);
}When Cookie Draft Breaks Down#
- Large drafts: cookies inflate request headers and can hit size limits.
- Sensitive information: you should not expose it in a client-stored medium.
- Multi-device resume: cookies are per browser.
- Auditing: no trail.
If any of these apply, move to DB drafts.
# Approach 2: Database Draft (Most Reliable)#
DB drafts are the most robust option for production:
- Users can resume later.
- You can support multi-device.
- You can implement TTL cleanup.
- You can safely store larger payloads.
Minimal Draft Table#
Keep it simple: id, userId or anonKey, data json, status, timestamps.
| Column | Type | Notes |
|---|---|---|
| id | string | Draft identifier |
| userId | string nullable | Required if authenticated |
| anonKey | string nullable | If anonymous draft |
| data | JSON | Partial payload merged per step |
| status | enum | draft, submitted, expired |
| updatedAt | timestamp | For cleanup and concurrency |
| createdAt | timestamp | For TTL |
💡 Tip: Add
updatedAtand enforce optimistic concurrency. IfupdatedAtchanged since the user loaded the step, you can show “This draft was updated elsewhere” instead of overwriting.
Draft ID Handling#
You need a stable draftId. Common patterns:
- Logged-in user: one active draft per wizard type.
- Anonymous: draftId stored in an httpOnly cookie.
Server Action with DB Merge (Pseudo DB Layer)#
The action:
- 1Validates step input.
- 2Loads draft by
draftId. - 3Merges data.
- 4Saves.
- 5Redirects.
// app/wizard/details/actions.ts
"use server";
import { redirect } from "next/navigation";
import { DetailsStepSchema } from "@/lib/schemas/projectInquiry";
import type { ActionState } from "@/lib/forms/errors";
async function getDraftId(): Promise<string> {
// Implement: from session, cookie, or user.
return "draft_123";
}
async function loadDraft(draftId: string): Promise<Record<string, unknown>> {
// Replace with your DB call.
return {};
}
async function saveDraft(draftId: string, data: Record<string, unknown>) {
// Replace with your DB call.
return;
}
export async function submitDetails(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const input = {
projectType: String(formData.get("projectType") ?? ""),
timeline: String(formData.get("timeline") ?? ""),
notes: String(formData.get("notes") ?? "") || undefined,
};
const parsed = DetailsStepSchema.safeParse(input);
if (!parsed.success) {
const f = parsed.error.flatten().fieldErrors;
return {
ok: false,
fieldErrors: {
projectType: f.projectType?.[0],
timeline: f.timeline?.[0],
notes: f.notes?.[0],
},
message: "Please fix the errors and try again.",
};
}
const draftId = await getDraftId();
const existing = await loadDraft(draftId);
await saveDraft(draftId, { ...existing, ...parsed.data });
redirect("/wizard/budget");
}Final Submit Action: Full Validation + Create Record#
At the end, do not trust step-level validation alone. Validate the whole draft using the full schema.
// app/wizard/review/actions.ts
"use server";
import { redirect } from "next/navigation";
import { FullInquirySchema } from "@/lib/schemas/projectInquiry";
import type { ActionState } from "@/lib/forms/errors";
async function getDraftId(): Promise<string> {
return "draft_123";
}
async function loadDraft(draftId: string): Promise<Record<string, unknown>> {
return {};
}
async function markSubmitted(draftId: string) {
return;
}
async function createInquiry(data: unknown) {
// Insert into DB, enqueue email, etc.
return { id: "inq_001" };
}
export async function submitFinal(
_prev: ActionState,
_formData: FormData
): Promise<ActionState> {
const draftId = await getDraftId();
const draft = await loadDraft(draftId);
const parsed = FullInquirySchema.safeParse(draft);
if (!parsed.success) {
return {
ok: false,
message: "Some required information is missing. Please review previous steps.",
};
}
const inquiry = await createInquiry(parsed.data);
await markSubmitted(draftId);
redirect(`/wizard/success?id=${encodeURIComponent(inquiry.id)}`);
}DB Draft Cleanup and Cost#
If you run 10,000 drafts per month and keep them for 7 days, you’re storing 70,000 rows. With a small JSON payload, this is trivial for Postgres, but you should implement TTL cleanup to control growth.
For example, a daily job deleting drafts where updatedAt is older than 30 days is usually enough for B2B flows. If you support resume links, extend TTL and require email verification.
# Approach 3: URL State (Best for Non‑Sensitive, Shareable Steps)#
URL state works when:
- The “draft” is mostly choices, not typed text.
- It’s okay that the data is visible in the address bar.
- You want shareable and back-button-friendly behavior.
Examples:
- Pricing configurator.
- Plan selection.
- Feature toggles.
Example: Plan Selection in Query Params#
On submit, redirect to next step with query params. On the server, validate query params with Zod.
// lib/schemas/plan.ts
import { z } from "zod";
export const PlanQuerySchema = z.object({
plan: z.enum(["starter", "pro", "enterprise"]),
seats: z.coerce.number().int().min(1).max(500),
});// app/wizard/plan/page.tsx
import { redirect } from "next/navigation";
export default function PlanStep({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
async function next(formData: FormData) {
"use server";
const plan = String(formData.get("plan") ?? "");
const seats = String(formData.get("seats") ?? "1");
redirect(`/wizard/details?plan=${encodeURIComponent(plan)}&seats=${encodeURIComponent(seats)}`);
}
return (
<main>
<h1>Step 1: Choose plan</h1>
<form action={next}>
<label>
Plan
<select name="plan" defaultValue="starter">
<option value="starter">Starter</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
</select>
</label>
<label>
Seats
<input name="seats" defaultValue="1" inputMode="numeric" />
</label>
<button type="submit">Next</button>
</form>
</main>
);
}Validating URL State on the Next Step#
// app/wizard/details/page.tsx
import { PlanQuerySchema } from "@/lib/schemas/plan";
export default async function DetailsStep({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const sp = await searchParams;
const parsed = PlanQuerySchema.safeParse({
plan: sp.plan,
seats: sp.seats,
});
if (!parsed.success) {
// Render a helpful message and link back; avoid throwing for user mistakes.
return (
<main>
<h1>Invalid plan selection</h1>
<p>Please go back and choose a valid plan.</p>
<a href="/wizard/plan">Back to plan</a>
</main>
);
}
return (
<main>
<h1>Step 2: Details</h1>
<p>
You selected {parsed.data.plan} with {parsed.data.seats} seats.
</p>
{/* Continue wizard */}
</main>
);
}Limitations You Should Accept Upfront#
- URLs have length constraints. Browsers vary, but long notes and rich data are a no-go.
- Query params are exposed in logs, analytics, and referrers. Never put emails, phone numbers, or free-text user input there.
- You still need server-side validation on final submit.
# Navigation Design: Back, Direct Step Access, and Guards#
Most wizards need predictable navigation rules:
- 1Back should not drop data.
- 2Users should not jump to Step 3 without completing Step 1, unless the flow allows it.
- 3Refresh should not wipe progress.
Step Guards#
On each step page (server component), load draft and redirect if prerequisites are missing.
Example: If email is missing, redirect to contact.
// app/wizard/budget/page.tsx
import { redirect } from "next/navigation";
import { getDraftFromCookie } from "@/lib/draft/cookieDraft";
export default function BudgetStep() {
const draft = getDraftFromCookie();
if (!draft.email) {
redirect("/wizard/contact");
}
return (
<main>
<h1>Step 3 of 3: Budget</h1>
{/* render form */}
</main>
);
}For DB drafts, the guard loads from DB instead. This prevents “direct URL to step” issues and reduces confusing partial submissions.
# Optimistic UX Without Lying to Users#
Optimistic UX in wizards is mostly about:
- Immediate feedback during submit.
- Preventing double submits.
- Keeping the UI stable.
Use:
useFormStatusto show pending state.- Disable submit while pending.
- Keep a small “Saving…” label close to the button.
If you need inline optimistic previews, make them derived from client state, but never assume the server accepted changes until the redirect happens.
🎯 Key Takeaway: Treat redirects as the confirmation signal. If the server action redirected, data persisted and the step is complete. If it returned state, show errors and keep the user on the same step.
# Error Handling: Validation Errors vs System Failures#
You want two paths:
- Expected errors: Zod validation, missing fields, invalid transitions. Return structured errors to render in the form.
- Unexpected failures: DB down, thrown exceptions. Let them bubble to error boundaries and show a reliable fallback.
Where to Put Error Boundaries#
Add error.tsx per wizard segment, and a loading.tsx if some steps fetch draft data.
Use this guide to structure boundaries correctly: Next.js error boundaries, loading, streaming patterns
Example: Segment Error UI#
// app/wizard/error.tsx
"use client";
export default function WizardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<main>
<h1>Something went wrong</h1>
<p>We could not save your progress. Please try again.</p>
<button onClick={reset}>Retry</button>
<p style={{ opacity: 0.7 }}>Reference: {error.digest ?? "n/a"}</p>
</main>
);
}System Failure Pattern in Server Actions#
For DB errors, do not swallow silently. Throw and let the error boundary handle it, or return a generic message only if you can recover.
Also consider idempotency: if a user double-clicks and you create two final records, that’s expensive. For final submission, use a unique constraint, or check if draft is already submitted.
# Accessibility Notes That Actually Affect Conversion#
Accessibility is not just compliance; it reduces abandonment. Baymard’s large-scale checkout research repeatedly shows that unclear errors and lost progress are top abandonment drivers in multi-step flows.
Practical checklist you can implement quickly:
| Item | Why it matters | Implementation |
|---|---|---|
| Step heading and progress text | Orients screen readers and reduces confusion | h1 like “Step 2 of 3” |
| Field errors announced | Users understand what failed | role="alert" on error text |
| Connect input to error | Screen readers can discover error | aria-describedby="field-error-id" |
| Mark invalid fields | Helps AT and some browsers | aria-invalid="true" |
| Focus management | Fast recovery after submit | Focus first invalid field when state has errors |
| Buttons are real buttons | Keyboard and semantics | button type="submit", avoid clickable divs |
Focusing the First Invalid Field#
You can implement a tiny client-side helper. Keep it minimal and only run when state changes to invalid.
// app/wizard/_components/useFocusFirstError.ts
"use client";
import { useEffect } from "react";
export function useFocusFirstError(fieldErrors?: Record<string, string | undefined>) {
useEffect(() => {
if (!fieldErrors) return;
const firstKey = Object.keys(fieldErrors).find((k) => fieldErrors[k]);
if (!firstKey) return;
const el = document.querySelector(`[name="${firstKey}"]`) as HTMLElement | null;
el?.focus();
}, [fieldErrors]);
}Use it in a step:
// app/wizard/contact/page.tsx (snippet)
import { useFocusFirstError } from "../_components/useFocusFirstError";
export default function ContactStep() {
const [state, action] = useActionState(submitContact, { ok: true });
useFocusFirstError(state.fieldErrors);
// ...
}# Putting It Together: Recommended Architecture#
For most product teams, the cleanest structure is:
- A
wizardroute group with step folders. - Step pages as client components only when they need interactive error rendering.
- Server actions per step under
actions.ts. - Zod schemas in
lib/schemas. - Draft layer in
lib/draftwith interchangeable storage.
This keeps the wizard consistent with the patterns covered in:
# Common Pitfalls (and How to Avoid Them)#
- 1
Relying only on step validation
Always do final full validation before creating the record. Users can skip steps via direct navigation or stale tabs. - 2
Overusing cookies
Cookie drafts fail on size and privacy. If you have free-text notes, attachments, or long flows, use DB drafts. - 3
Throwing Zod errors as exceptions
Validation errors are expected. Return them as action state so the form can render field-level messages. - 4
Ignoring double-submit/idempotency
Disable submit during pending and protect final creation with a unique constraint or “draft already submitted” check. - 5
Not guarding steps
Always load draft and enforce prerequisites. Otherwise you’ll get broken review pages and confusing “missing field” failures.
# Key Takeaways#
- Choose draft persistence based on risk and duration: cookies for short non-sensitive flows, DB drafts for reliable resume and multi-device, URL state for shareable non-sensitive selections.
- Use Zod twice: per-step schemas for immediate feedback and a full schema before final submission to prevent skipped-step data gaps.
- Treat redirect as the success signal for each step; return structured
fieldErrorsstate for expected validation failures. - Implement accessibility basics that reduce abandonment:
role="alert",aria-describedby,aria-invalid, and focus the first invalid field after submit. - Separate expected errors from system failures: render validation errors inline, and rely on route error boundaries for exceptions and infrastructure issues.
# Conclusion#
A Next.js multi step form with server actions can be simpler and more reliable than an API-heavy approach, as long as you pick the right draft strategy and validate on every step and on final submit.
If you want Samioda to implement this wizard end-to-end with production-grade persistence, accessibility, and analytics, contact us via samioda.com and share your flow and requirements.
FAQ
Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.
More in Web Development
All →Next.js App Router Forms in 2026: React Hook Form + Zod + Server Actions (Validation, Errors, UX)
A production-ready architecture for Next.js App Router forms using React Hook Form, Zod, and Server Actions — with shared schemas, secure server-side validation, async checks, file uploads, and consistent error handling.
Offline-Friendly React Apps with TanStack Query: Persistence, Retries, and Optimistic UI (2026 Guide)
Build resilient offline-first UX with TanStack Query: React Query offline persistence, retry and backoff strategies, safe optimistic updates, partial offline patterns, and MSW testing.
React Virtualization Guide: Windowing Large Lists and Grids with TanStack Virtual (and When Not To)
A practical 2026 guide to React virtualization with TanStack Virtual: build fast lists and grids, add infinite loading and sticky headers, profile measurable gains, and avoid common edge cases like dynamic row heights and accessibility pitfalls.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Server Actions in Next.js App Router: Production Form Patterns for Validation, Errors, and Optimistic UI
A production-ready guide to Next.js Server Actions form validation with Zod, structured error handling, progressive enhancement, optimistic UI, and rate limiting — plus when to choose Server Actions vs API routes.
React Forms at Scale: React Hook Form + Zod Patterns for Complex Products
React forms best practices for large apps using React Hook Form and Zod: schema-first validation, reusable fields, async checks, multi-step flows, performance, accessibility, and server/API integration patterns.
Next.js App Router Forms in 2026: React Hook Form + Zod + Server Actions (Validation, Errors, UX)
A production-ready architecture for Next.js App Router forms using React Hook Form, Zod, and Server Actions — with shared schemas, secure server-side validation, async checks, file uploads, and consistent error handling.