Multi-step Forms

Create wizard-style forms with state management, progressive validation, and navigation for complex data entry workflows using the NuxtUI Stepper component.

Why Multi-step Forms

  • ✅ Break complex forms into manageable steps
  • ✅ Improve user experience with progressive disclosure
  • ✅ Reduce form abandonment rates
  • ✅ Validate progressively to catch errors early
  • ✅ Provide clear visual progress indicators

Using NuxtUI Stepper

The UStepper component from NuxtUI provides a beautiful, accessible stepper UI out of the box.

Basic Setup

<script setup lang="ts">
import type { StepperItem } from '@nuxt/ui'

const steps = ref<StepperItem[]>([
  { 
    title: 'Account', 
    description: 'Create your account',
    icon: 'i-lucide-user'
  },
  { 
    title: 'Profile', 
    description: 'Complete your profile',
    icon: 'i-lucide-contact'
  },
  { 
    title: 'Review', 
    description: 'Review and confirm',
    icon: 'i-lucide-check-circle'
  }
])

const currentStep = ref(0)
</script>

<template>
  <UStepper v-model="currentStep" :items="steps" />
</template>

Complete Registration Example

Form State Management

<script setup lang="ts">
import type { StepperItem } from '@nuxt/ui'

const steps = ref<StepperItem[]>([
  { 
    title: 'Account', 
    description: 'Create your account',
    icon: 'i-lucide-user'
  },
  { 
    title: 'Profile', 
    description: 'Complete your profile',
    icon: 'i-lucide-contact'
  },
  { 
    title: 'Preferences', 
    description: 'Set your preferences',
    icon: 'i-lucide-settings'
  }
])

const currentStep = ref(0)

// Centralized form data
const formData = ref({
  // Step 1
  email: '',
  password: '',
  // Step 2  
  firstName: '',
  lastName: '',
  company: '',
  // Step 3
  notifications: true,
  theme: 'light'
})

const errors = ref<Record<string, string>>({})

function validateStep(step: number): boolean {
  errors.value = {}
  
  if (step === 0) {
    if (!formData.value.email) {
      errors.value.email = 'Email is required'
      return false
    }
    if (!formData.value.password || formData.value.password.length < 8) {
      errors.value.password = 'Password must be at least 8 characters'
      return false
    }
  } else if (step === 1) {
    if (!formData.value.firstName) {
      errors.value.firstName = 'First name is required'
      return false
    }
    if (!formData.value.lastName) {
      errors.value.lastName = 'Last name is required'
      return false
    }
  }
  
  return true
}

function nextStep() {
  if (validateStep(currentStep.value)) {
    currentStep.value++
  }
}

function previousStep() {
  currentStep.value--
}

async function submit() {
  if (!validateStep(currentStep.value)) return
  
  await $fetch('/api/register', {
    method: 'POST',
    body: formData.value
  })
}
</script>

<template>
  <div class="max-w-3xl mx-auto">
    <!-- Stepper Component -->
    <UStepper 
      v-model="currentStep"
      :items="steps" 
    />
    
    <!-- Step Content -->
    <div class="mt-8">
      <!-- Step 1: Account -->
      <div v-if="currentStep === 0">
        <UFormGroup label="Email" :error="errors.email">
          <UInput v-model="formData.email" type="email" />
        </UFormGroup>
        
        <UFormGroup label="Password" :error="errors.password" class="mt-4">
          <UInput v-model="formData.password" type="password" />
        </UFormGroup>
      </div>
      
      <!-- Step 2: Profile -->
      <div v-else-if="currentStep === 1">
        <UFormGroup label="First Name" :error="errors.firstName">
          <UInput v-model="formData.firstName" />
        </UFormGroup>
        
        <UFormGroup label="Last Name" :error="errors.lastName" class="mt-4">
          <UInput v-model="formData.lastName" />
        </UFormGroup>
        
        <UFormGroup label="Company (optional)" class="mt-4">
          <UInput v-model="formData.company" />
        </UFormGroup>
      </div>
      
      <!-- Step 3: Preferences -->
      <div v-else>
        <UFormGroup label="Notifications">
          <UToggle v-model="formData.notifications" />
        </UFormGroup>
        
        <UFormGroup label="Theme" class="mt-4">
          <URadioGroup 
            v-model="formData.theme" 
            :options="[
              { value: 'light', label: 'Light' },
              { value: 'dark', label: 'Dark' }
            ]" 
          />
        </UFormGroup>
      </div>
    </div>
    
    <!-- Navigation -->
    <div class="flex justify-between mt-8">
      <UButton 
        v-if="currentStep > 0"
        @click="previousStep"
        variant="outline"
      >
        Previous
      </UButton>
      <div v-else />
      
      <UButton 
        v-if="currentStep < steps.length - 1"
        @click="nextStep"
      >
        Next
      </UButton>
      <UButton 
        v-else
        @click="submit"
        color="green"
      >
        Submit
      </UButton>
    </div>
  </div>
</template>

Progressive Validation with Zod

Combine NuxtUI Stepper with Zod for robust validation:

import { z } from 'zod'

// Define schemas per step
const accountSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(8, 'Password must be at least 8 characters')
})

const profileSchema = z.object({
  firstName: z.string().min(2, 'First name required'),
  lastName: z.string().min(2, 'Last name required')
})

function validateStep(step: number): boolean {
  try {
    if (step === 0) {
      accountSchema.parse({
        email: formData.value.email,
        password: formData.value.password
      })
    } else if (step === 1) {
      profileSchema.parse({
        firstName: formData.value.firstName,
        lastName: formData.value.lastName
      })
    }
    errors.value = {}
    return true
  } catch (error) {
    if (error instanceof z.ZodError) {
      const fieldErrors = error.flatten().fieldErrors
      errors.value = Object.fromEntries(
        Object.entries(fieldErrors).map(([key, val]) => [key, val?.[0] || ''])
      )
    }
    return false
  }
}

Draft Saving

Save form progress to resume later:

// app/composables/useDraftSaving.ts
export function useDraftSaving<T>(key: string, initialData: T) {
  const formData = ref<T>(initialData)
  const currentStep = ref(0)
  
  // Load draft on mount
  onMounted(() => {
    if (import.meta.client) {
      const saved = localStorage.getItem(key)
      if (saved) {
        const draft = JSON.parse(saved)
        formData.value = draft.data
        currentStep.value = draft.step
      }
    }
  })
  
  // Save draft on change
  watch([formData, currentStep], () => {
    if (import.meta.client) {
      localStorage.setItem(key, JSON.stringify({
        data: formData.value,
        step: currentStep.value,
        savedAt: new Date().toISOString()
      }))
    }
  }, { deep: true })
  
  function clearDraft() {
    if (import.meta.client) {
      localStorage.removeItem(key)
    }
  }
  
  return {
    formData,
    currentStep,
    clearDraft
  }
}

Usage:

<script setup lang="ts">
const { formData, currentStep, clearDraft } = useDraftSaving('registration-form', {
  email: '',
  password: '',
  firstName: '',
  lastName: ''
})

async function submit() {
  await $fetch('/api/register', {
    method: 'POST',
    body: formData.value
  })
  
  clearDraft() // Remove draft after successful submission
}
</script>

Prevent accidental navigation away with unsaved changes:

// app/composables/useFormGuard.ts
export function useFormGuard(isDirty: Ref<boolean>) {
  const router = useRouter()
  
  onBeforeRouteLeave((to, from) => {
    if (isDirty.value) {
      const answer = window.confirm(
        'You have unsaved changes. Do you really want to leave?'
      )
      if (!answer) return false
    }
  })
  
  onMounted(() => {
    if (import.meta.client) {
      window.addEventListener('beforeunload', handleBeforeUnload)
    }
  })
  
  onUnmounted(() => {
    if (import.meta.client) {
      window.removeEventListener('beforeunload', handleBeforeUnload)
    }
  })
  
  function handleBeforeUnload(e: BeforeUnloadEvent) {
    if (isDirty.value) {
      e.preventDefault()
      e.returnValue = ''
    }
  }
}

Best Practices

  1. ✅ Use NuxtUI Stepper for consistent, accessible UI
  2. ✅ Validate each step before allowing progression
  3. ✅ Save drafts to improve user experience
  4. ✅ Provide clear error messages
  5. ✅ Allow navigation back to previous steps
  6. ✅ Show overall progress clearly
  7. ✅ Use route guards to prevent accidental data loss
  8. ✅ Consider mobile responsiveness

Advanced Patterns

Dynamic Steps

const steps = computed<StepperItem[]>(() => {
  const baseSteps = [
    { title: 'Account', icon: 'i-lucide-user' },
    { title: 'Profile', icon: 'i-lucide-contact' }
  ]
  
  // Add optional step based on user choice
  if (formData.value.needsCompanyInfo) {
    baseSteps.push({ 
      title: 'Company', 
      icon: 'i-lucide-building' 
    })
  }
  
  baseSteps.push({ 
    title: 'Review', 
    icon: 'i-lucide-check-circle' 
  })
  
  return baseSteps
})

Step Validation Summary

<script setup lang="ts">
const stepValidation = ref({
  0: false, // Account step
  1: false, // Profile step
  2: false  // Preferences step
})

function updateStepValidation(step: number, isValid: boolean) {
  stepValidation.value[step] = isValid
}

const steps = computed<StepperItem[]>(() => [
  {
    title: 'Account',
    icon: stepValidation.value[0] 
      ? 'i-lucide-check-circle' 
      : 'i-lucide-user'
  },
  {
    title: 'Profile',
    icon: stepValidation.value[1] 
      ? 'i-lucide-check-circle' 
      : 'i-lucide-contact'
  },
  {
    title: 'Preferences',
    icon: stepValidation.value[2] 
      ? 'i-lucide-check-circle' 
      : 'i-lucide-settings'
  }
])
</script>

Multi-step forms with NuxtUI Stepper provide an excellent user experience for complex data entry. Combine with validation, draft saving, and navigation guards for a production-ready solution. v-model="formData.confirmPassword" type="password" placeholder="••••••••" />

  <!-- Step 2: Profile -->
  <div v-if="currentStep.id === 'profile'" class="step-content">
    <h2>{{ currentStep.title }}</h2>
    
    <div class="form-field">
      <label>First Name</label>
      <input
        v-model="formData.firstName"
        type="text"
        placeholder="John"
      />
    </div>
    
    <div class="form-field">
      <label>Last Name</label>
      <input
        v-model="formData.lastName"
        type="text"
        placeholder="Doe"
      />
    </div>
    
    <div class="form-field">
      <label>Phone (Optional)</label>
      <input
        v-model="formData.phone"
        type="tel"
        placeholder="+1 234 567 8900"
      />
    </div>
  </div>
  
  <!-- Step 3: Preferences -->
  <div v-if="currentStep.id === 'preferences'" class="step-content">
    <h2>{{ currentStep.title }}</h2>
    
    <div class="form-field">
      <label>
        <input v-model="formData.newsletter" type="checkbox" />
        Subscribe to newsletter
      </label>
    </div>
    
    <div class="form-field">
      <label>
        <input v-model="formData.notifications" type="checkbox" />
        Enable notifications
      </label>
    </div>
    
    <div class="form-field">
      <label>Theme</label>
      <select v-model="formData.theme">
        <option value="light">Light</option>
        <option value="dark">Dark</option>
        <option value="auto">Auto</option>
      </select>
    </div>
  </div>
  
  <!-- Step 4: Review -->
  <div v-if="currentStep.id === 'review'" class="step-content">
    <h2>{{ currentStep.title }}</h2>
    
    <div class="review-section">
      <h3>Account</h3>
      <p><strong>Email:</strong> {{ formData.email }}</p>
    </div>
    
    <div class="review-section">
      <h3>Profile</h3>
      <p><strong>Name:</strong> {{ formData.firstName }} {{ formData.lastName }}</p>
      <p v-if="formData.phone"><strong>Phone:</strong> {{ formData.phone }}</p>
    </div>
    
    <div class="review-section">
      <h3>Preferences</h3>
      <p><strong>Newsletter:</strong> {{ formData.newsletter ? 'Yes' : 'No' }}</p>
      <p><strong>Notifications:</strong> {{ formData.notifications ? 'Yes' : 'No' }}</p>
      <p><strong>Theme:</strong> {{ formData.theme }}</p>
    </div>
  </div>
</div>

<!-- Navigation Buttons -->
<div class="form-actions">
  <button
    v-if="!isFirstStep"
    type="button"
    @click="previousStep"
  >
    Previous
  </button>
  
  <button
    v-if="!isLastStep"
    type="button"
    @click="handleNext"
  >
    Next
  </button>
  
  <button
    v-if="isLastStep"
    type="button"
    @click="handleSubmit"
  >
    Submit
  </button>
</div>

Draft Saving

Auto-save Composable

// app/composables/useFormDraft.ts
import { useDebounceFn, useLocalStorage } from '@vueuse/core'

export function useFormDraft<T>(
  key: string,
  formData: Ref<T>,
  options: {
    debounce?: number
    storageType?: 'local' | 'session'
  } = {}
) {
  const { debounce = 1000, storageType = 'local' } = options
  
  const storage = storageType === 'local' ? useLocalStorage : useSessionStorage
  const draft = storage<T | null>(key, null)
  
  // Save draft on data change
  const saveDraft = useDebounceFn(() => {
    draft.value = formData.value
  }, debounce)
  
  watch(formData, saveDraft, { deep: true })
  
  function loadDraft(): T | null {
    return draft.value
  }
  
  function clearDraft() {
    draft.value = null
  }
  
  function hasDraft(): boolean {
    return draft.value !== null
  }
  
  return {
    loadDraft,
    clearDraft,
    hasDraft
  }
}

Using Draft Saving

const { formData, updateFormData } = useRegistrationForm()

const { loadDraft, clearDraft, hasDraft } = useFormDraft(
  'registration-draft',
  formData,
  { debounce: 2000 }
)

// On mount, check for draft
onMounted(() => {
  if (hasDraft()) {
    const shouldRestore = confirm('Resume previous registration?')
    if (shouldRestore) {
      const draft = loadDraft()
      if (draft) {
        updateFormData(draft)
      }
    } else {
      clearDraft()
    }
  }
})

// Clear draft on successful submission
async function handleSubmit() {
  const success = await submitForm()
  if (success) {
    clearDraft()
    navigateTo('/dashboard')
  }
}

Prevent Accidental Navigation

<script setup lang="ts">
const { isDirty } = useRegistrationForm()

// Browser navigation warning
onBeforeRouteLeave((to, from, next) => {
  if (isDirty.value) {
    const answer = window.confirm(
      'You have unsaved changes. Are you sure you want to leave?'
    )
    if (answer) {
      next()
    } else {
      next(false)
    }
  } else {
    next()
  }
})

// Browser close/reload warning
onMounted(() => {
  const handleBeforeUnload = (e: BeforeUnloadEvent) => {
    if (isDirty.value) {
      e.preventDefault()
      e.returnValue = ''
    }
  }
  
  window.addEventListener('beforeunload', handleBeforeUnload)
  
  onUnmounted(() => {
    window.removeEventListener('beforeunload', handleBeforeUnload)
  })
})
</script>

Progressive Validation

Real-time Field Validation

<script setup lang="ts">
import { z } from 'zod'

const email = ref('')
const emailError = ref('')

const emailSchema = z.string().email()

// Validate on blur
const validateEmail = useDebounceFn(() => {
  const result = emailSchema.safeParse(email.value)
  emailError.value = result.success ? '' : 'Invalid email address'
}, 300)

watch(email, validateEmail)
</script>

<template>
  <div class="form-field">
    <label>Email</label>
    <input
      v-model="email"
      type="email"
      :class="{ error: emailError }"
    />
    <span v-if="emailError" class="error">{{ emailError }}</span>
    <span v-else-if="email && !emailError" class="success">✓ Valid</span>
  </div>
</template>

Conditional Steps

export function useConditionalStepForm() {
  const formData = ref({ accountType: 'personal' })
  
  const steps = computed(() => {
    const baseSteps = [
      { id: 'account-type', title: 'Account Type' },
      { id: 'basic-info', title: 'Basic Information' }
    ]
    
    // Add business step only for business accounts
    if (formData.value.accountType === 'business') {
      baseSteps.push({
        id: 'business-info',
        title: 'Business Information'
      })
    }
    
    baseSteps.push({ id: 'review', title: 'Review' })
    
    return baseSteps
  })
  
  return useMultiStepForm(steps.value, formData.value)
}

Best Practices

✅ Do's

  1. ✅ Save drafts automatically for long forms
  2. ✅ Show clear progress indicators
  3. ✅ Validate each step before proceeding
  4. ✅ Allow users to go back and edit previous steps
  5. ✅ Provide visual feedback for completed steps
  6. ✅ Warn users before losing unsaved changes
  7. ✅ Keep step titles short and descriptive

❌ Don'ts

  1. ❌ Don't make forms longer than necessary
  2. ❌ Don't validate on every keystroke (use debounce)
  3. ❌ Don't hide the progress indicator
  4. ❌ Don't lose data when navigating between steps
  5. ❌ Don't submit without final review step
  6. ❌ Don't forget to clear sensitive data from storage

Advanced Patterns

Step-by-step Async Validation

async function checkEmailAvailability(email: string): Promise<boolean> {
  const { available } = await $fetch('/api/check-email', {
    params: { email }
  })
  return available
}

const steps = [
  {
    id: 'account',
    title: 'Account',
    validate: async (data) => {
      const result = accountSchema.safeParse(data)
      if (!result.success) return false
      
      // Async validation
      const available = await checkEmailAvailability(data.email)
      if (!available) {
        throw new Error('Email already registered')
      }
      
      return true
    }
  }
]

Branching Logic

function getNextStep(currentStepId: string, formData: any): string {
  if (currentStepId === 'account-type') {
    return formData.accountType === 'business' 
      ? 'business-info' 
      : 'personal-info'
  }
  // Default sequential flow
  return steps[currentStepIndex.value + 1].id
}

Multi-step forms improve complex data entry workflows. Implement progressive validation, draft saving, and navigation guards for the best user experience.

Advanced Data Tables
Build enterprise-grade data tables with filtering, sorting, pagination, inline editing, bulk actions, and export capabilities.
Backend-for-Frontend (BFF) Pattern
Learn how to implement the BFF pattern in Nuxt to aggregate multiple APIs, transform data, and provide a unified interface for your frontend.
Files
Editor
Initializing WebContainer
Mounting files
Installing dependencies
Starting Nuxt server
Waiting for Nuxt to ready
Terminal