How to Build a Multi-Step Form in SvelteKit

Multi-Step Forms: Breaking Complexity into Steps

Long forms with 10+ fields intimidate users. Multi-step forms (also called wizard forms) solve this by breaking the form into logical sections with progress indication. The user fills one step, clicks “Next,” and moves to the next group of fields.

This tutorial shows how to build a multi-step form in SvelteKit using Superforms for validation and a state machine for step management. The final form validates each step independently and only submits when the user completes all steps.

Architecture: One Schema, Multiple Steps

There are two approaches to multi-step validation:

  1. One schema, partial validation per step — define the full schema, but only validate the fields visible in the current step
  2. Separate schemas per step — each step has its own Zod schema, merged on final submission

We’ll use approach 1 because it’s simpler and keeps the full form state in one object.

The Full Schema

import { z } from 'zod';

export const onboardingSchema = z.object({
  // Step 1: Personal Info
  firstName: z.string().min(2, 'First name is required'),
  lastName: z.string().min(2, 'Last name is required'),
  email: z.string().email('Please enter a valid email'),

  // Step 2: Company Info
  company: z.string().min(2, 'Company name is required'),
  role: z.enum(['developer', 'designer', 'manager', 'other']),
  teamSize: z.coerce.number().min(1).max(10000),

  // Step 3: Preferences
  notifications: z.boolean().default(true),
  theme: z.enum(['light', 'dark', 'system']),
  newsletter: z.boolean().default(false)
});

export type OnboardingSchema = typeof onboardingSchema;

// Fields per step — used for partial validation
export const stepFields = [
  ['firstName', 'lastName', 'email'],
  ['company', 'role', 'teamSize'],
  ['notifications', 'theme', 'newsletter']
] as const;

Step Management with Svelte 5 State

The step state is simple — a reactive number that controls which fields are visible:

let currentStep = $state(0);
const totalSteps = stepFields.length;

function canGoNext(form, errors) {
  const fields = stepFields[currentStep];
  return fields.every(f => !errors[f] && form[f] !== undefined && form[f] !== '');
}

function nextStep() {
  if (currentStep < totalSteps - 1) currentStep++;
}

function prevStep() {
  if (currentStep > 0) currentStep--;
}

The Server Action

import { superValidate, message } from 'sveltekit-superforms/server';
import { zod } from 'sveltekit-superforms/adapters';
import { fail } from '@sveltejs/kit';
import { onboardingSchema } from './schema';
import type { Actions, PageServerLoad } from './$types';

export const load: PageServerLoad = async () => {
  const form = await superValidate(zod(onboardingSchema));
  return { form };
};

export const actions: Actions = {
  default: async ({ request }) => {
    const form = await superValidate(request, zod(onboardingSchema));

    if (!form.valid) {
      return fail(400, { form });
    }

    // All steps validated — process the complete form
    await createUserOnboarding(form.data);

    return message(form, 'Onboarding complete!');
  }
};

The server validates the full schema on submission. It doesn’t know about steps — it sees the complete form data and validates everything.

The Component: Step-by-Step UI

<script lang="ts">
  import { superForm } from 'sveltekit-superforms';
  import { zod } from 'sveltekit-superforms/adapters';
  import { onboardingSchema, stepFields } from './schema';
  import type { PageData } from './$types';

  let { data } = $props();
  let currentStep = $state(0);

  const { form, errors, enhance, submitting } = superForm(data.form, {
    validators: zod(onboardingSchema)
  });
</script>

<!-- Progress bar -->
<div class="flex gap-2 mb-8">
  {#each stepFields as _, i}
    <div class="h-2 flex-1 rounded-full {i <= currentStep ? 'bg-blue-600' : 'bg-gray-200'}"></div>
  {/each}
</div>

<form method="POST" use:enhance>
  {#if currentStep === 0}
    <h2>Personal Information</h2>
    <!-- firstName, lastName, email fields -->
  {/if}

  {#if currentStep === 1}
    <h2>Company Details</h2>
    <!-- company, role, teamSize fields -->
  {/if}

  {#if currentStep === 2}
    <h2>Preferences</h2>
    <!-- notifications, theme, newsletter fields -->
  {/if}

  <div class="flex justify-between mt-6">
    {#if currentStep > 0}
      <button type="button" onclick={() => currentStep--}>Back</button>
    {/if}
    {#if currentStep < 2}
      <button type="button" onclick={() => currentStep++}>Next</button>
    {:else}
      <button type="submit" disabled={$submitting}>Complete</button>
    {/if}
  </div>
</form>

Handling Step Validation

The tricky part: you want to validate only the current step’s fields when the user clicks “Next,” but validate everything when they click “Complete.” Superforms’ client-side validators run on all fields by default.

One approach: validate the full form client-side but only show errors for the current step’s fields. The step navigation checks if the current step’s fields have errors before allowing “Next”:

function validateAndNext() {
  const currentFields = stepFields[currentStep];
  const hasErrors = currentFields.some(f => $errors[f]);
  const hasEmpty = currentFields.some(f => !$form[f] && f !== 'newsletter' && f !== 'notifications');

  if (!hasErrors && !hasEmpty) {
    currentStep++;
  }
}

Persisting Step Progress

If the user refreshes mid-wizard, they lose their progress. For long multi-step forms, consider saving progress to localStorage:

// Save on every field change
$effect(() => {
  localStorage.setItem('onboarding-draft', JSON.stringify({
    step: currentStep,
    data: $form
  }));
});

// Restore on mount
import { onMount } from 'svelte';
onMount(() => {
  const saved = localStorage.getItem('onboarding-draft');
  if (saved) {
    const { step, data } = JSON.parse(saved);
    currentStep = step;
    Object.assign($form, data);
  }
});

When to Use Multi-Step Forms

  • 10+ fields — anything shorter works fine as a single page
  • Logical groupings — fields should form coherent sections (personal, payment, shipping)
  • Progressive disclosure — later steps depend on earlier answers
  • Onboarding flows — welcome wizards, profile setup

For simpler forms (contact, login, newsletter), stick to single-page forms. You can generate those instantly with the SvelteForms builder or start from a template.

See also: client + server validation and signup form with password validation.

Build forms faster

Try the form builder →