A checkout split into steps is kinder than one long page, and harder to build. The data has to survive moving between steps. A visitor who types /checkout/review into the address bar must not land on a review of nothing. A reload should not wipe what they typed. And the order should go to the server once, complete, at the end.
The three-step checkout below (contact, delivery, review) handles all four with one Pinia setup store, a Zod schema per step, and a route per step behind a navigation guard: a function the router runs before it shows a page. It is a client-rendered Vite app on Vue 3.5, Pinia 3.0, Zod 4.4 and Vue Router 5.1.
Why the data lives in a store
The first version most people write keeps each step's fields inside the step component. With a route per step, <RouterView> shows one step at a time, and that is where it breaks:
<script setup lang="ts">
import { reactive } from 'vue'
const delivery = reactive({ street: '', city: '', postcode: '', method: 'standard' })
</script>
Fill in the delivery step, go back to fix a typo in your email, and come forward again. Every field is empty. Leaving the route unmounted the component, and its state went with it.
The usual next attempt moves the state into a parent and passes it down as props, with an update event coming back up from every field. That works for two steps in one component. With routes it gets awkward: <RouterView> renders the steps, not your parent, so every prop and event has to pass through it. And the navigation guard, which runs outside any component, also needs to know which steps are complete. A store is reachable from the steps, the guard and the submit code alike:
<script setup lang="ts">
import { useCheckoutStore } from '../stores/checkout'
const checkout = useCheckoutStore()
</script>
<template>
<label for="street">Street and number</label>
<input id="street" v-model="checkout.draft.delivery.street" autocomplete="address-line1" />
</template>
The component now owns nothing. It shows a slice of the store and writes to it, so unmounting it loses nothing.
Write one Zod schema per step
A Zod schema describes what valid data looks like and checks values against it. Each step gets its own, so you can ask "is this step done?" without caring about the others. Install the libraries with npm install pinia zod vue-router, then describe the rules once:
import * as z from 'zod'
export const contactSchema = z.object({
name: z.string().trim().min(2, 'Enter your full name'),
email: z.email('Enter a valid email address'),
})
export const deliverySchema = z.object({
street: z.string().trim().min(3, 'Enter a street and house number'),
city: z.string().trim().min(2, 'Enter a town or city'),
postcode: z.string().trim().regex(/^[A-Z0-9 ]{3,10}$/i, 'Enter a valid postcode'),
method: z.enum(['standard', 'express']),
})
export const reviewSchema = z.object({
acceptTerms: z.literal(true, 'Accept the terms to place your order'),
})
export const STEPS = ['contact', 'delivery', 'review'] as const
export type Step = (typeof STEPS)[number]
export interface CheckoutDraft {
contact: { name: string; email: string }
delivery: { street: string; city: string; postcode: string; method: 'standard' | 'express' }
review: { acceptTerms: boolean }
}
z.email() is Zod 4's top-level email check, and each rule takes its error message as the last argument. z.literal(true) only accepts true, which is how you say "this box must be ticked".
STEPS lists the steps in order; the store and the guard both use it. The CheckoutDraft type is written by hand because a draft is allowed to be invalid: acceptTerms starts as false, which the schema would reject.
Keep the whole draft in a setup store
A setup store is a function that returns refs, computed values and functions, just like a composable. This one holds the whole draft:
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { STEPS, contactSchema, deliverySchema, reviewSchema, type CheckoutDraft, type Step } from '../checkout/schemas'
const STORAGE_KEY = 'checkout-draft'
function emptyDraft(): CheckoutDraft {
return {
contact: { name: '', email: '' },
delivery: { street: '', city: '', postcode: '', method: 'standard' },
review: { acceptTerms: false },
}
}
function loadDraft(): CheckoutDraft {
try {
const saved = sessionStorage.getItem(STORAGE_KEY)
return saved ? { ...emptyDraft(), ...(JSON.parse(saved) as Partial<CheckoutDraft>) } : emptyDraft()
} catch {
return emptyDraft()
}
}
export const useCheckoutStore = defineStore('checkout', () => {
const draft = ref<CheckoutDraft>(loadDraft())
const submitting = ref(false)
const valid = computed<Record<Step, boolean>>(() => ({
contact: contactSchema.safeParse(draft.value.contact).success,
delivery: deliverySchema.safeParse(draft.value.delivery).success,
review: reviewSchema.safeParse(draft.value.review).success,
}))
// The furthest step a visitor may open: the first one that is not valid yet.
const furthestStep = computed<Step>(() => STEPS.find((step) => !valid.value[step]) ?? 'review')
watch(draft, (value) => sessionStorage.setItem(STORAGE_KEY, JSON.stringify(value)), { deep: true })
function reset() {
draft.value = emptyDraft()
}
async function submit(): Promise<string> {
const order = {
contact: contactSchema.parse(draft.value.contact),
delivery: deliverySchema.parse(draft.value.delivery),
}
submitting.value = true
try {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order),
})
if (!res.ok) throw new Error(`The order failed (status ${res.status}). Please try again.`)
const { id } = (await res.json()) as { id: string }
return id
} finally {
submitting.value = false
}
}
return { draft, submitting, valid, furthestStep, reset, submit }
})
Three parts do the real work. valid runs safeParse for each step, which never throws and returns an object with success. Because it is a computed, it re-runs whenever a field changes, and every component that reads it stays current.
furthestStep turns those three booleans into a single answer: the first step that is not done yet. With an empty draft that is contact. Once contact and delivery are valid it is review.
The watch saves the whole draft to sessionStorage on every change, and loadDraft reads it back when the store is first used. Spreading the saved object over emptyDraft() fills in any section an older saved draft lacks, and the try covers storage that is blocked or JSON that will not parse.
Session storage belongs to one browser tab and is cleared when the tab closes, which suits a checkout. Any script on your page can still read it, so never put card numbers or passwords in a draft you persist.
The store touches sessionStorage while it is created, so this version is for a client-rendered app. In a server-rendered app, create the draft with emptyDraft() and load the saved one in a component's onMounted hook, which runs only in the browser.
Give each step a route and block skipping ahead
Each step gets a URL, so the browser's back button works and a reload lands on the same step. The guard reads furthestStep and sends anyone who skips ahead back to the first unfinished step:
import { createRouter, createWebHistory } from 'vue-router'
import { STEPS, type Step } from '../checkout/schemas'
import { useCheckoutStore } from '../stores/checkout'
declare module 'vue-router' {
interface RouteMeta {
step?: Step
}
}
export const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/checkout',
component: () => import('../checkout/CheckoutLayout.vue'),
children: [
{ path: '', redirect: { name: 'checkout-contact' } },
{ path: 'contact', name: 'checkout-contact', component: () => import('../checkout/ContactStep.vue'), meta: { step: 'contact' } },
{ path: 'delivery', name: 'checkout-delivery', component: () => import('../checkout/DeliveryStep.vue'), meta: { step: 'delivery' } },
{ path: 'review', name: 'checkout-review', component: () => import('../checkout/ReviewStep.vue'), meta: { step: 'review' } },
],
},
{ path: '/order/:id', name: 'order-placed', component: () => import('../checkout/OrderPlaced.vue') },
],
})
router.beforeEach((to) => {
const step = to.meta.step
if (!step) return
const furthest = useCheckoutStore().furthestStep
if (STEPS.indexOf(step) > STEPS.indexOf(furthest)) {
return { name: `checkout-${furthest}`, replace: true }
}
})
The declare module block types meta.step, so a typo such as 'deliver' is a compile error. The guard returns nothing for every route without a step, and for any step at or before the furthest one, so the page it redirects to always passes. replace: true replaces the current history entry instead of adding one, so the redirect leaves no extra step for the back button.
The routes import two files this post does not show. CheckoutLayout.vue needs only a <RouterView /> where the current step appears. OrderPlaced.vue is a thank-you page that reads the order id from the route.
Keep the useCheckoutStore() call inside the guard. At the top of router/index.ts it would run as soon as main.ts imports the router, before Pinia is installed; in development Pinia stops with "getActivePinia()" was called but there was no active Pinia. In main.ts, call app.use(createPinia()) before app.use(router).
Build a step that edits the store and shows errors
Every step follows the same pattern: bind the inputs to the store, show errors only after the first attempt to continue, and move on when the step is valid.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import * as z from 'zod'
import { contactSchema, type CheckoutDraft } from './schemas'
import { useCheckoutStore } from '../stores/checkout'
const checkout = useCheckoutStore()
const router = useRouter()
const showErrors = ref(false)
const errors = computed<Partial<Record<keyof CheckoutDraft['contact'], string[]>>>(() => {
const result = contactSchema.safeParse(checkout.draft.contact)
return result.success ? {} : z.flattenError(result.error).fieldErrors
})
function next() {
showErrors.value = true
if (checkout.valid.contact) router.push({ name: 'checkout-delivery' })
}
</script>
<template>
<form novalidate @submit.prevent="next">
<label for="name">Full name</label>
<input id="name" v-model="checkout.draft.contact.name" autocomplete="name" />
<p v-if="showErrors && errors.name" role="alert">{{ errors.name[0] }}</p>
<label for="email">Email</label>
<input id="email" v-model="checkout.draft.contact.email" type="email" autocomplete="email" />
<p v-if="showErrors && errors.email" role="alert">{{ errors.email[0] }}</p>
<button type="submit">Continue to delivery</button>
</form>
</template>
z.flattenError() groups the issues by field name, which is the shape a template wants. novalidate turns off the browser's own bubbles so the Zod messages are the only ones, and role="alert" makes a screen reader announce each message when it appears. The delivery step is the same file with four fields and deliverySchema.
Try the pieces together. Fill in the contact step, continue, and reload on /checkout/delivery. The store reads the draft back from session storage, furthestStep is delivery, and the guard lets the page through. Now type /checkout/review into the address bar with the delivery fields still empty: you land on the delivery step, because that is the first one not done.
Submit the order once, then reset
The review step shows what will be sent, asks for the terms box, and places the order:
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useCheckoutStore } from '../stores/checkout'
const checkout = useCheckoutStore()
const router = useRouter()
const error = ref('')
async function placeOrder() {
if (!checkout.valid.review || checkout.submitting) return
error.value = ''
try {
const id = await checkout.submit()
await router.replace({ name: 'order-placed', params: { id } })
checkout.reset()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
}
}
</script>
<template>
<form @submit.prevent="placeOrder">
<h2>Check your order</h2>
<p>{{ checkout.draft.contact.name }}, {{ checkout.draft.contact.email }}</p>
<p>{{ checkout.draft.delivery.street }}, {{ checkout.draft.delivery.city }} ({{ checkout.draft.delivery.method }})</p>
<label>
<input v-model="checkout.draft.review.acceptTerms" type="checkbox" />
I accept the terms of sale
</label>
<button type="submit" :disabled="!checkout.valid.review || checkout.submitting">Place order</button>
<p v-if="error" role="alert">{{ error }}</p>
</form>
</template>
Look back at submit() in the store. It uses parse(), not safeParse(), because by now invalid data would be a bug, and parse() returns the cleaned values: trimmed names and a typed delivery method.
The request goes out once, with the contact and delivery details together, so the server never sees half an order. While it is in flight, submitting disables the button and makes placeOrder return early, so a double click cannot create two orders.
The reset happens after the navigation, so the review page never flashes empty fields. Resetting replaces the draft, the watcher saves the empty version, and the next checkout in this tab starts clean. If the request fails, nothing is reset, and the visitor can try again with everything still filled in.