Web Development
Next.jsReactApp RouterServer ActionsZodFormsAccessibility

Building a Multi‑Step Wizard in Next.js App Router with Server Actions + Zod (No Extra API Layer)

AO
Adrijan Omićević
·16 min read

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

StrategyWhere draft livesBest forProsConsAvoid when
Cookies or sessionEncrypted cookie or server sessionShort wizards, low-risk dataFast setup, no DB required, easy redirectsCookie size limits, sensitive data risk, harder multi-devicePayment info, long drafts, large payloads
DB draftDatabase row keyed by draftIdLong wizards, logged-in users, multi-deviceDurable, auditable, supports resumeNeeds DB, cleanup/TTL, more logicTiny forms where DB is overkill
URL stateQuery params per stepFilters, plan selections, non-sensitive choicesShareable URLs, great back button behaviorExposes data, URL length limitsAnything 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:

  1. 1
    Per-step schema to validate what the user just submitted.
  2. 2
    Full schema to validate everything before final submission.

Example Wizard: Project Inquiry#

Steps:

  1. 1
    Contact
  2. 2
    Project details
  3. 3
    Budget and review

We’ll represent the draft as a single object.

TypeScript
// 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.

TypeScript
// 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.

Keep cookies small. Realistically, you have a few kilobytes. Avoid large free-text fields.

TypeScript
// 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.

TypeScript
// 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.

TSX
// 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>
  );
}
  • 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.

ColumnTypeNotes
idstringDraft identifier
userIdstring nullableRequired if authenticated
anonKeystring nullableIf anonymous draft
dataJSONPartial payload merged per step
statusenumdraft, submitted, expired
updatedAttimestampFor cleanup and concurrency
createdAttimestampFor TTL

💡 Tip: Add updatedAt and enforce optimistic concurrency. If updatedAt changed 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:

  1. 1
    Validates step input.
  2. 2
    Loads draft by draftId.
  3. 3
    Merges data.
  4. 4
    Saves.
  5. 5
    Redirects.
TypeScript
// 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.

TypeScript
// 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.

TypeScript
// 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),
});
TypeScript
// 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#

TypeScript
// 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.

Most wizards need predictable navigation rules:

  1. 1
    Back should not drop data.
  2. 2
    Users should not jump to Step 3 without completing Step 1, unless the flow allows it.
  3. 3
    Refresh 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.

TypeScript
// 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:

  • useFormStatus to 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#

TSX
// 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:

ItemWhy it mattersImplementation
Step heading and progress textOrients screen readers and reduces confusionh1 like “Step 2 of 3”
Field errors announcedUsers understand what failedrole="alert" on error text
Connect input to errorScreen readers can discover erroraria-describedby="field-error-id"
Mark invalid fieldsHelps AT and some browsersaria-invalid="true"
Focus managementFast recovery after submitFocus first invalid field when state has errors
Buttons are real buttonsKeyboard and semanticsbutton 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.

TSX
// 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:

TSX
// 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);
 
  // ...
}

For most product teams, the cleanest structure is:

  • A wizard route 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/draft with interchangeable storage.

This keeps the wizard consistent with the patterns covered in:

# Common Pitfalls (and How to Avoid Them)#

  1. 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. 2

    Overusing cookies
    Cookie drafts fail on size and privacy. If you have free-text notes, attachments, or long flows, use DB drafts.

  3. 3

    Throwing Zod errors as exceptions
    Validation errors are expected. Return them as action state so the form can render field-level messages.

  4. 4

    Ignoring double-submit/idempotency
    Disable submit during pending and protect final creation with a unique constraint or “draft already submitted” check.

  5. 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 fieldErrors state 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

Share
A
Adrijan OmićevićFounder & Senior Developer

Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.