---
title: "Multi-step forms with a Pinia store · vuejs.school"
description: "A three-step checkout with one Pinia setup store, a Zod schema per step, a route per step, a guard against skipping ahead and a draft that survives reloads."
canonical_url: "https://vuejs.school/community/blog/multi-step-forms-with-a-pinia-store"
meta:
  "og:description": "A three-step checkout with one Pinia setup store, a Zod schema per step, a route per step, a guard against skipping ahead and a draft that survives reloads."
  "og:image": "https://vuejs.school/_og/d/a_~TXVsdGktc3RlcCBmb3JtcyB3aXRoIGEgUGluaWEgc3RvcmUgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Multi-step%2Bforms%2Bwith%2Ba%2BPinia%2Bstore%2Cdescription_~QSB0aHJlZS1zdGVwIGNoZWNrb3V0IHdpdGggb25lIFBpbmlhIHNldHVwIHN0b3JlLCBhIFpvZCBzY2hlbWEgcGVyIHN0ZXAsIGEgcm91dGUgcGVyIHN0ZXAsIGEgZ3VhcmQgYWdhaW5zdCBza2lwcGluZyBhaGVhZCBhbmQgYSBkcmFmdCB0aGF0IHN1cnZpdmVzIHJlbG9hZHMu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9tdWx0aS1zdGVwLWZvcm1zLXdpdGgtYS1waW5pYS1zdG9yZSI%2Cs_YXcUdfqBaldHHgv2.png"
  "og:image:alt": "Multi-step forms with a Pinia store · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Multi-step forms with a Pinia store · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~TXVsdGktc3RlcCBmb3JtcyB3aXRoIGEgUGluaWEgc3RvcmUgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Multi-step%2Bforms%2Bwith%2Ba%2BPinia%2Bstore%2Cdescription_~QSB0aHJlZS1zdGVwIGNoZWNrb3V0IHdpdGggb25lIFBpbmlhIHNldHVwIHN0b3JlLCBhIFpvZCBzY2hlbWEgcGVyIHN0ZXAsIGEgcm91dGUgcGVyIHN0ZXAsIGEgZ3VhcmQgYWdhaW5zdCBza2lwcGluZyBhaGVhZCBhbmQgYSBkcmFmdCB0aGF0IHN1cnZpdmVzIHJlbG9hZHMu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9tdWx0aS1zdGVwLWZvcm1zLXdpdGgtYS1waW5pYS1zdG9yZSI%2Cs_YXcUdfqBaldHHgv2.png"
  "twitter:image:alt": "Multi-step forms with a Pinia store · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

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

*src/checkout/DeliveryStep.vue**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:

*src/checkout/DeliveryStep.vue**Works***

```
<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:

*src/checkout/schemas.ts*

```
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:

*src/stores/checkout.ts*

```
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:

*src/router/index.ts*

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

*src/checkout/ContactStep.vue*

```
<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:

*src/checkout/ReviewStep.vue*

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

**Check it stuck**3 questions from the Form state and validation by hand quiz.

[**Practice now**](https://vuejs.school/learn/forms/form-state-and-validation-by-hand?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 6 min read**Accessible form errors: aria-describedby, live regions and focus**](https://vuejs.school/community/blog/accessible-form-errors) [Tutorial · 6 min read**Schema validation with Zod 4 and VeeValidate**](https://vuejs.school/community/blog/schema-validation-with-zod-4-and-veevalidate)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/multi-step-forms-with-a-pinia-store#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-07-14","description":"A three-step checkout with one Pinia setup store, a Zod schema per step, a route per step, a guard against skipping ahead and a draft that survives reloads.","name":"Multi-step forms with a Pinia store","url":"https://vuejs.school/community/blog/multi-step-forms-with-a-pinia-store","breadcrumb":{"@id":"https://vuejs.school/community/blog/multi-step-forms-with-a-pinia-store#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/multi-step-forms-with-a-pinia-store"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/multi-step-forms-with-a-pinia-store#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-07-14","description":"A three-step checkout with one Pinia setup store, a Zod schema per step, a route per step, a guard against skipping ahead and a draft that survives reloads.","headline":"Multi-step forms with a Pinia store","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~TXVsdGktc3RlcCBmb3JtcyB3aXRoIGEgUGluaWEgc3RvcmUgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Multi-step%2Bforms%2Bwith%2Ba%2BPinia%2Bstore%2Cdescription_~QSB0aHJlZS1zdGVwIGNoZWNrb3V0IHdpdGggb25lIFBpbmlhIHNldHVwIHN0b3JlLCBhIFpvZCBzY2hlbWEgcGVyIHN0ZXAsIGEgcm91dGUgcGVyIHN0ZXAsIGEgZ3VhcmQgYWdhaW5zdCBza2lwcGluZyBhaGVhZCBhbmQgYSBkcmFmdCB0aGF0IHN1cnZpdmVzIHJlbG9hZHMu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9tdWx0aS1zdGVwLWZvcm1zLXdpdGgtYS1waW5pYS1zdG9yZSI%2Cs_YXcUdfqBaldHHgv2.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/multi-step-forms-with-a-pinia-store#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/multi-step-forms-with-a-pinia-store#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/multi-step-forms-with-a-pinia-store#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Multi-step forms with a Pinia store","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~TXVsdGktc3RlcCBmb3JtcyB3aXRoIGEgUGluaWEgc3RvcmUgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Multi-step%2Bforms%2Bwith%2Ba%2BPinia%2Bstore%2Cdescription_~QSB0aHJlZS1zdGVwIGNoZWNrb3V0IHdpdGggb25lIFBpbmlhIHNldHVwIHN0b3JlLCBhIFpvZCBzY2hlbWEgcGVyIHN0ZXAsIGEgcm91dGUgcGVyIHN0ZXAsIGEgZ3VhcmQgYWdhaW5zdCBza2lwcGluZyBhaGVhZCBhbmQgYSBkcmFmdCB0aGF0IHN1cnZpdmVzIHJlbG9hZHMu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9tdWx0aS1zdGVwLWZvcm1zLXdpdGgtYS1waW5pYS1zdG9yZSI%2Cs_YXcUdfqBaldHHgv2.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~TXVsdGktc3RlcCBmb3JtcyB3aXRoIGEgUGluaWEgc3RvcmUgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Multi-step%2Bforms%2Bwith%2Ba%2BPinia%2Bstore%2Cdescription_~QSB0aHJlZS1zdGVwIGNoZWNrb3V0IHdpdGggb25lIFBpbmlhIHNldHVwIHN0b3JlLCBhIFpvZCBzY2hlbWEgcGVyIHN0ZXAsIGEgcm91dGUgcGVyIHN0ZXAsIGEgZ3VhcmQgYWdhaW5zdCBza2lwcGluZyBhaGVhZCBhbmQgYSBkcmFmdCB0aGF0IHN1cnZpdmVzIHJlbG9hZHMu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9tdWx0aS1zdGVwLWZvcm1zLXdpdGgtYS1waW5pYS1zdG9yZSI%2Cs_YXcUdfqBaldHHgv2.png"}]}
```