Skip to content
Chapter 79Lesson 1

Project overview

Course progress bar.

You are going to add a “New customer” flow to the customers surface you built in the production list view project — not a single cramped form, but a four-step wizard, one route segment per step: /customers/new/step-1 for contact details, step-2 for billing, step-3 for preferences, and step-4 to review and create. Fill in step 1, click Next, the URL changes and step 2 loads. Click Back and step 1 is still there, every field exactly as you left it — and that’s the part a naive implementation gets wrong, because each step is its own page and a new page normally means fresh state. The draft survives the trip because one Zustand store is pinned above all four routes. The Next button refuses to advance until the current step’s fields pass their Zod schema, so you can’t carry a malformed contact into billing. Step 4 reads back everything you entered, and “Create customer” hands the whole draft to a Server Action that re-validates it, writes the row, and redirects you to the new customer’s detail page with the wizard wiped clean behind you.

There is one deliberate product decision baked into this design, and it’s worth naming before you build it: refresh the page mid-flow and the draft is gone. The store lives in memory, not in the URL and not in a cookie, so a hard reload starts you over at an empty step 1. That is the right call for a short wizard like this one — the alternative, surviving a refresh, means persisting a half-finished draft on the server, which is real work you only take on when the form is long enough that losing it would genuinely hurt. You’ll see that trade-off stated on the screen itself.

A single desktop Screenshot of the running solution. Hero shot: /customers/new/step-4 — the review screen showing the Contact, Billing, and Preferences sections filled in from a completed run, the progress header reading “Step 4 of 4” with the first three pips marked complete, and the primary “Create customer” button below the review. Capture from the running solution, do not invent the layout.

This is where the Zustand primitives from the previous chapter stop being isolated demos and become one running feature — the canonical shape every routed multi-step surface reuses. By the end you will have practiced:

  • Standing up a per-feature store the App Router way: a createStore factory from zustand/vanilla, a provider pinned in a useRef on a shared layout so one instance survives navigation, and a typed selector hook that’s the only door into the store.
  • Splitting the draft into slices — contact, billing, preferences, and a meta slice tracking the current step — and reading each field through an atomic selector so typing in one input re-renders only that input.
  • Gating progress on validation that lives in one place: a per-step Zod schema that the Next button checks on the client and the Server Action re-checks on the server, the same schema on both sides.
  • Closing the loop through a Server Action — the single seam where the client store meets the server — with a pending guard against double-submit, a success-only reset, and a redirect.

This is the skeleton you reach for whenever a form is too big for one screen and a modal won’t do — a routed checkout, multi-step settings, an onboarding flow. Build it once here and you own the pattern.

Five boxes describe the whole shape, and one relationship is the point of the whole project. None of this is built in this lesson — it’s the map you’ll keep coming back to.

An ArrowDiagram (horizontal, capped height) laying out the layering. Boxes:

  • A top row of two boxes labelled “Customers list (Server Component)” and “Customer detail (Server Component)” — the production-list-view surface, untouched.
  • A box labelled “/customers/new layout — WizardStoreProvider” sitting below, representing the single store instance mounted on the shared layout.
  • Below the provider, a cluster of leaf boxes: “Step 1–4 pages” and “Footer (Next-gate)”, labelled “Client Components · atomic selectors”, reading from the provider.
  • To the right, a box “createCustomer (Server Action)” with a sub-label “pushCustomer + customer.created audit, org-scoped”. The one arrow that carries meaning: from a “Submit button” node (inside the step-4 leaf cluster) to the “createCustomer (Server Action)” box, labelled “the only seam”. The diagram’s message is spatial: Server Components above, the client store subtree below the provider, and exactly one crossing point into the server.

A few things to read off that map. The customers list and detail pages are Server Components and the wizard does not touch them — the store is client-only and never crosses into a Server Component or a Server Action body. The provider lives on the /customers/new layout, not on any step page, because the layout is what stays mounted as you navigate between the four segments; mount the provider on a step page instead and every Next click re-creates the store and wipes the draft. The step pages and the footer are leaf Client Components that subscribe to the store through atomic selectors — each one reads the single field or action it needs. And the submit button is the only place the in-memory draft becomes a server write: the createCustomer Server Action re-parses the payload, inserts the customer, and writes the customer.created audit entry, with the organization scoped server-side from the session — the store knows nothing about which org it belongs to. How the slices compose, how a selector stays surgical, and how the action maps the draft onto a customer row are decisions the build lessons own. Don’t worry about the mechanics yet.

The starter and the finished solution share one file tree — no files are added or removed across the whole project. Your work is edits inside the stubbed files, and the tree below highlights them: every file carrying a TODO is yours to write, and everything else is provided whole. That “everything else” is a lot — the entire customers list and detail surface from the production-list-view project, the in-memory store that stands in for Postgres, the per-step Zod schemas, the store’s type definitions, the progress header, and the inspector. You write the store internals, the forms, and the submit path; you read the rest.

Annotated top-level layout of the starter. Bold (highlighted focus) every file carrying a TODO; leave provided files uncommented except where a one-line comment helps orient. Render approximately:

  • src/
    • app/
      • (app)/customers/
        • page.tsx — provided: customers list (Server Component, from the production-list-view project)
        • [id]/page.tsx — provided: customer detail (Server Component)
        • new/
          • layout.tsx — provided: mounts WizardStoreProvider + progress + footer on the shared layout
          • wizard-progress.tsx — provided: reads currentStep + completedSteps
          • footer.tsx — TODO: Back/Next, Next gates on validity
          • step-1/page.tsx → TODO: contact fields
          • step-2/page.tsx → TODO: billing fields
          • step-3/page.tsx → TODO: preferences controls
          • step-4/page.tsx → TODO: review of the three slices
          • step-4/submit-button.tsx → TODO: pending guard, calls the action, resets, redirects
          • _lib/wizard/
            • wizard-types.ts — provided (read-only): the slice and store types + initialWizardData
            • schemas.ts — provided: contactSchema, billingSchema, preferencesSchema, createCustomerInput
            • contact-slice.ts — TODO
            • billing-slice.ts — TODO
            • preferences-slice.ts — TODO
            • meta-slice.ts — TODO: currentStep, completedSteps, goNext, goBack
            • store.ts — TODO: compose the four slices via createStore + reset
            • selectors.ts — TODO: atomic selectors + selectIsStepValid / selectStepErrors
            • actions.ts — TODO: the createCustomer Server Action
          • _components/
            • wizard-store-provider.tsx — TODO: useRef-pinned store + Context
            • use-wizard-store.ts — TODO: typed useWizardStore(selector)
            • use-broadcast-snapshot.ts — provided: mirrors the store to the inspector
            • use-broadcast-render.ts — provided: reports render counts to the inspector
      • inspector/ — provided: the verification surface (iframed wizard + store snapshot)
    • server/
      • store.ts — provided: in-memory globalThis store standing in for Postgres
      • session.ts — provided: cookie-backed dev session, no auth wall
    • lib/ — provided: result, authed-action, audit-log, debug-flags, customers queries

The grouping is deliberate, and it’s the same instinct you’ve applied to features all course long. Everything store-related lives under app/(app)/customers/new/_lib/wizard/ — the slices, the composed store, the schemas, the selectors, and the action are neighbours, not scattered across a global lib/. The provider and the typed hook sit in the sibling _components/ because they’re the React wiring that exposes that store to components. Nothing under _lib/wizard/ or _components/ is imported anywhere outside this wizard: there is no app-wide Zustand store, and there won’t be. A store is for the feature that owns it.

Two files in that directory you’ll read but never edit: wizard-types.ts holds the slice and store types and the initialWizardData constant, and schemas.ts holds the four Zod schemas. They’re given to you so the build lessons can lean on a fixed contract — your slices implement those types, your selectors and action consume those schemas. The customers list and detail pages don’t change at all.

One file worth a closer look up front is src/server/store.ts. There is no Postgres in this project, no Drizzle, no migration, and no seed script to run — this module is the database. It pins an in-memory store on globalThis and self-seeds on first import with two organizations, four users, and a set of invoices and customers each, so the customers list has rows to show and the Server Action has somewhere to write. next.config.ts keeps cacheComponents on from the production-list-view project: the customers list above the wizard stays cached, and the wizard routes are leaf Client Components that don’t interact with that cache at all.

Last, the inspector at app/inspector/. It’s the surface every build lesson verifies against — a dashboard with an identity and org switcher, a live mirror of the store’s contents, a re-render counter, and buttons to force failures and flip debug flags. It’s worth knowing one thing about how it works, because it looks like it shouldn’t: the inspector sits outside the wizard, so it never mounts the provider and can’t read the store directly. Instead it opens the wizard in an <iframe> and the wizard broadcasts its state out via postMessage — that’s what the provided use-broadcast-snapshot.ts and use-broadcast-render.ts do. You don’t write any of that wiring; you just drive the inspector to confirm the store behaves.

Three implementation lessons turn this map into the working feature, each closing on a state you can run.

  • Card title “Lesson 2 — Build the store skeleton”: Composes the four-slice store through a vanilla createStore factory, pins it in a useRef provider on the shared layout, and adds the typed hook — so the wizard navigates across all four routes with one store surviving every step.
  • Card title “Lesson 3 — Wire the forms and the Next-gate”: Binds each field through an atomic selector, renders inline Zod errors, and wires the footer so Next enables only when the current slice is valid and advances both the store and the URL together.
  • Card title “Lesson 4 — Submit, reset, and guard”: Adds the composite-payload Server Action, the step-4 review, and the submit button with its pending and double-submit guard, the success-only reset, and the redirect.

There is no infrastructure to stand up. The starter is the customers codebase plus everything the wizard needs, and the in-memory store self-seeds on first import — so pnpm install and pnpm dev are the whole setup.

  1. Get the starter codebase from the project repository, under Chapter 079/start/.

  2. Install dependencies. zustand already ships in the starter’s package.json, over the production-list-view project’s dependencies.

    Terminal window
    pnpm install
  3. Start the dev server.

    Terminal window
    pnpm dev

The root redirects to /customers, where the seeded customers list renders end to end — search, the table, and pagination all work, exactly as the production-list-view project left them. Click “New customer” or jump to /customers/new/step-1 and the step-1 shell loads: the progress header reads “Step 1 of 4” with the first pip lit, the four contact fields render, and the Next button sits disabled. Type into a field and nothing sticks — the slice setters are no-op stubs, so the store never updates and Next never enables. That’s the correct starting state: the provider and the store boot, but every field and the whole submit path are unwritten. Visit /inspector and the dashboard loads with the wizard running in its iframe and the store-snapshot panel mirroring the initial empty store — currentStep: 1, every slice blank. That snapshot is the instrument you’ll watch as you build. The next lesson lands the first real piece: the store composed and surviving navigation across all four steps.