Project overview
Course progress bar.
You’ll add a “New customer” flow to the customers surface from the production list view project: not one 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, step-4 to review and create.
Fill in step 1, click Next, and step 2 loads at a new URL.
Click Back and every field is exactly as you left it.
A naive version gets that wrong: each step is its own page, and a new page normally means fresh state.
The draft survives because one Zustand store sits above all four routes.
Next refuses to advance until the current step passes its Zod schema, so a malformed contact can’t reach billing.
Step 4 reads everything back, and “Create customer” hands the draft to a Server Action that re-validates it, writes the row, and redirects to the new customer’s page with the wizard cleared.
One trade-off is deliberate: the store lives in memory, not the URL or a cookie, so a refresh mid-flow drops the draft and returns you to an empty step 1. For a short wizard that’s the right call — surviving a refresh means persisting a half-finished draft on the server, work you take on only when losing the form would actually hurt.
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.
What we’ll practice
Section titled “What we’ll practice”This build turns the previous chapter’s Zustand primitives into one running feature. By the end you will have practiced:
- Wiring a per-feature store the App Router way: a
createStorefactory fromzustand/vanilla, a provider held in auseRefon a shared layout so one instance survives navigation, and a typed selector hook as the only way in. - Splitting the draft into slices — contact, billing, preferences, and a meta slice for the current step — and reading each field through an atomic selector , so typing in one input re-renders only that input.
- Validating with one per-step Zod schema, checked by the Next button on the client and re-checked by the Server Action on the server.
- Closing the loop through a Server Action, the single seam between client store and server, with a pending guard against double-submit, a success-only reset, and a redirect.
Reach for this skeleton when a form is too big for one screen and a modal won’t do: a routed checkout, multi-step settings, an onboarding flow.
Architecture
Section titled “Architecture”This is the reference for the whole chapter, not something you build in this lesson.
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.
The customers list and detail pages stay Server Components, untouched, because the client-only store never reaches them.
The provider sits on the /customers/new layout, not a step page, so it stays mounted across the four segments; on a step page, each navigation would re-create the store and wipe the draft.
The step pages and footer are leaf Client Components, each subscribing through an atomic selector to the one field or action it needs.
The submit button is the only place the draft becomes a server write: createCustomer re-parses the payload, inserts the customer, and writes the customer.created audit entry, with the organization scoped server-side from the session so the store never tracks which org it belongs to.
Starting file tree
Section titled “Starting file tree”Starter and solution share one tree; no files are added or removed.
You edit inside the stubbed files: every TODO is yours, everything else is provided whole.
You write the store internals, the forms, and the submit path; you read the rest — list and detail pages, in-memory store, Zod schemas, store types, progress header, and inspector.
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)
- (app)/customers/
- 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
- app/
Everything store-related lives under app/(app)/customers/new/_lib/wizard/: the slices, composed store, schemas, selectors, and action are neighbours.
The provider and typed hook sit in the sibling _components/, the React wiring that exposes the store.
Nothing under either folder is imported outside this wizard; there is no app-wide Zustand store, because a store belongs to the feature that owns it.
Two files you read but never edit fix a contract: wizard-types.ts holds the slice and store types plus initialWizardData, schemas.ts holds the four Zod schemas.
Your slices implement those types; your selectors and action consume those schemas.
There is no Postgres, Drizzle, migration, or seed script: src/server/store.ts is the database.
It pins an in-memory store on globalThis and self-seeds on first import, so the customers list has rows and the Server Action has somewhere to write.
next.config.ts keeps cacheComponents on from the production-list-view project; the wizard routes are leaf Client Components that never touch that cache.
The inspector at app/inspector/ is what every build lesson verifies against.
It sits outside the wizard, so it never mounts the provider and can’t read the store directly: it opens the wizard in an <iframe> and reads the state the wizard broadcasts over postMessage — the job of the provided use-broadcast-snapshot.ts and use-broadcast-render.ts.
You don’t write that wiring; you drive the inspector to confirm the store behaves.
Roadmap
Section titled “Roadmap”Three lessons build the feature, each ending on a runnable state.
- Card title “Lesson 2 — Build the store skeleton”: Builds the four-slice store with a vanilla
createStorefactory, holds it in auseRefprovider on the shared layout, and adds the typed hook, so one store survives every step across all four routes. - Card title “Lesson 3 — Wire the forms and the Next-gate”: Binds each field through an atomic selector, shows inline Zod errors, and wires the footer so Next enables only when the current slice is valid, then advances 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 a submit button that guards against double submits, resets only on success, and redirects.
No infrastructure to stand up: the starter is the customers codebase plus everything the wizard needs, so pnpm install and pnpm dev are the whole setup.
-
Get the starter codebase from the project repository, under
Chapter 079/start/. -
Install dependencies.
zustandalready ships in the starter’spackage.json, over the production-list-view project’s dependencies.Terminal window pnpm install -
Start the dev server.
Terminal window pnpm dev
The root redirects to /customers, where the seeded list renders with working search, table, and pagination.
Open /customers/new/step-1 and the shell loads: the header reads “Step 1 of 4”, the four contact fields render, and Next is disabled.
Typing doesn’t stick: the slice setters are no-op stubs, so the store never updates. That’s the correct starting state.
Visit /inspector and the wizard runs in its iframe beside the store-snapshot panel, which mirrors the empty store: currentStep: 1, every slice blank.
That snapshot is the instrument you’ll watch as you build.