---
title: "Multi-step forms"
description: "Split one form into steps, validate each step before continuing, and submit everything at the end."
canonical_url: "https://notformdocs.vercel.app/docs/recipes/multi-step-forms"
---

> Index of every page on this site: [llms.txt](https://notformdocs.vercel.app/llms.txt)

A multi-step form is one form whose schema changes as the user moves through it. Each step renders a subset of the fields and validates against its own schema. Because values live in the form, they persist as users move between steps.

## Approach

1. Define one schema per step. Each schema extends the previous one, so the last schema describes the whole form.
2. Pick the active schema from the current step index and pass it to `useForm`.
3. Render a `type="submit"` button on every step. Submit validates the active schema, then calls `onSubmit`.
4. In `onSubmit`, advance to the next step. On the final step, send the values instead.

## Example

<code-collapse>

```vue
<script setup lang="ts">
import { Field, FieldError, Form, useForm } from 'notform'
import { computed, ref } from 'vue'
import { z } from 'zod'

const plans = ['free', 'pro'] as const

// Step 1: personal information.
const profileStepSchema = z.object({
  email: z.email('Enter a valid email address.'),
  name: z.string().min(1, 'Enter your name.'),
})

/*
 * Step 2: plan selection.
 * Each step's schema extends the previous one, so the final submit
 * receives every field the user entered, not only the last step's.
 */
const planStepSchema = profileStepSchema.extend({
  plan: z.enum(plans, { error: 'Choose a plan.' }),
})

const stepSchemas = [profileStepSchema, planStepSchema]

const stepIndex = ref(0)
const isFirstStep = computed(() => {
  return stepIndex.value === 0
})
const isLastStep = computed(() => {
  return stepIndex.value === stepSchemas.length - 1
})

// Validate only against the schema of the step the user is on.
const activeSchema = computed(() => {
  return stepSchemas[stepIndex.value]
})

const form = useForm({
  initialValues: { email: '', name: '' },
  schema: activeSchema,

  // Runs only after the active step has passed validation.
  async onSubmit(values) {
    if (!isLastStep.value) {
      stepIndex.value++
      return
    }

    const response = await fetch('/api/sign-up', {
      body: JSON.stringify(values),
      headers: { 'content-type': 'application/json' },
      method: 'POST',
    })

    if (!response.ok) {
      throw new Error('Sign-up failed.')
    }
  },
})

function goToPreviousStep() {
  if (!isFirstStep.value) {
    stepIndex.value--
  }
}
</script>

<template>
  <Form
    :form="form"
    novalidate
  >
    <!-- Step 1: Personal information -->
    <div v-if="stepIndex === 0">
      <Field
        v-slot="{ field }"
        :field="form.field('name')"
      >
        <label :for="field.path">Name</label>

        <input
          :id="field.path"
          v-model="field.model"
          v-bind="field.events"
        >

        <FieldError :errors="field" />
      </Field>

      <Field
        v-slot="{ field }"
        :field="form.field('email')"
      >
        <label :for="field.path">Email</label>

        <input
          :id="field.path"
          v-model="field.model"
          v-bind="field.events"
          type="email"
        >

        <FieldError :errors="field" />
      </Field>
    </div>

    <!-- Step 2: Plan selection -->
    <div v-else-if="stepIndex === 1">
      <Field
        v-slot="{ field }"
        :field="form.field('plan')"
      >
        <fieldset>
          <legend>Plan</legend>

          <label
            v-for="plan in plans"
            :key="plan"
          >
            <input
              v-model="field.model"
              v-bind="field.events"
              type="radio"
              :name="field.path"
              :value="plan"
            >
            {{ plan }}
          </label>
        </fieldset>

        <FieldError :errors="field" />
      </Field>
    </div>

    <!-- Navigation -->
    <button
      v-if="!isFirstStep"
      type="button"
      @click="goToPreviousStep"
    >
      Previous
    </button>

    <button
      type="submit"
      :disabled="form.isSubmitting"
    >
      {{ isLastStep ? 'Finish' : 'Next' }}
    </button>
  </Form>
</template>
```

</code-collapse>

## Why this works

- **Values persist.** `form.values` holds every step's data. Fields on hidden steps simply are not rendered, but their values remain.
- **Step validation is scoped.** The form validates against `activeSchema`, so fields from later steps cannot produce errors early.
- **Next needs no validity check.** `onSubmit` runs only after the active schema passes, so advancing the step there is enough.
- **The final submit is authoritative.** The last schema extends every earlier one, so it validates every field and `values` contains everything the user entered. Every field counts as touched.

## Notes

- Give every field a starting value, including steps the user has not reached. `undefined` is correct for a choice with no default, as with `plan`.
- Do not gate navigation on `form.isValid`. It is `true` before any validation has run, and validation is async, so reading it right after a click can let an invalid step through. Let `onSubmit` do the gating, as the example does.
- Next is a submit button, so pressing Enter in a field behaves like clicking Next: it validates the active step and advances. Previous is `type="button"`, so it never submits or validates.
- To keep step state in the URL, store `stepIndex` in a route query. NotForm does not need to know about it.

## With Pinia

The same approach works when the form lives in a Pinia store. Create the form in the store, render each step as its own component, and wrap the active step in `<KeepAlive>` so step components are cached while the user moves between them.

This helps when steps are large enough to live in separate files, or when something outside the form, such as a progress sidebar or a route guard, needs to read the current step.

<code-collapse>
<code-group>

```ts [stores/signup.ts]
import { useForm } from 'notform'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'

// `profileStepSchema` and `planStepSchema` are the same as above.
const stepSchemas = [profileStepSchema, planStepSchema]

export const useSignUpStore = defineStore('sign-up', () => {
  const stepIndex = ref(0)
  const isFirstStep = computed(() => {
    return stepIndex.value === 0
  })
  const isLastStep = computed(() => {
    return stepIndex.value === stepSchemas.length - 1
  })
  const activeSchema = computed(() => {
    return stepSchemas[stepIndex.value]
  })

  const form = useForm({
    initialValues: { email: '', name: '' },
    async onSubmit(values) {
      if (!isLastStep.value) {
        stepIndex.value++
      }

      // Send `values` to your API.
    },
    schema: activeSchema,
  })

  function goToPreviousStep() {
    if (!isFirstStep.value) {
      stepIndex.value--
    }
  }

  return { form, goToPreviousStep, isFirstStep, isLastStep, stepIndex }
})
```

```vue [components/sign-up-form.vue]
<script setup lang="ts">
import { Form } from 'notform'
import { useSignUpStore } from '../stores/sign-up'
import PlanStep from './PlanStep.vue'
import ProfileStep from './ProfileStep.vue'

const signUp = useSignUpStore()
const steps = [ProfileStep, PlanStep]
</script>

<template>
  <Form
    :form="signUp.form"
    novalidate
  >
    <KeepAlive>
      <component :is="steps[signUp.stepIndex]" />
    </KeepAlive>

    <button
      v-if="!signUp.isFirstStep"
      type="button"
      @click="signUp.goToPreviousStep"
    >
      Previous
    </button>

    <button
      type="submit"
      :disabled="signUp.form.isSubmitting"
    >
      {{ signUp.isLastStep ? 'Finish' : 'Next' }}
    </button>
  </Form>
</template>
```

</code-group>
</code-collapse>

Each step component reads the store and renders its own `Field`s, exactly like the single-file example.

- **Values live in the store, not in KeepAlive.** The form keeps every step's values either way. `KeepAlive` only preserves each step's component state, such as focus, scroll position, and local UI state, and skips re-mounting.
- **KeepAlive takes one child at a time.** Switching with `<component :is>` fits this naturally.
- **The store outlives the page.** Reset the form and `stepIndex` when the wizard finishes or the user leaves, or the next visit starts mid-way.

## Sitemap

See the full [sitemap](https://notformdocs.vercel.app/sitemap.md) for all pages.
