Przejdź do treści

Jak zbudować wielokrokowy formularz w Next.js?

Poznaj architekturę wieloetapowych formularzy w Next.js. Zobacz, jak bezpiecznie trzymać stan, walidować poszczególne kroki i zapobiec utracie danych.

Maciej Sala

Founder StriveLab

6 min czytaniaOpublikowano 11 kwietnia 2026 (Aktualizacja 14 lipca 2026)

Dlaczego formularz multi-step w Next.js zamiast jednego formularza?

Typowe zastosowania: formularz wyceny projektu, onboarding nowego użytkownika, checkout (adres → dostawa → płatność), rejestracja z weryfikacją.

Trzy elementy, które trzeba domknąć: gdzie trzymać , jak walidować każdy krok osobno i jak bezpiecznie wysłać całość na serwer. Po kolei. (Jeśli chcesz, by krok był zapisany w URL-u jako ?step=2 — z deep linkingiem i działającym „wstecz" przeglądarki — wzorzec synchronizacji stanu z searchParams opisałem w artykule o URL state w Next.js.)

Diagram
Przepływ danych: każdy krok waliduje swój wycinek schematu i dopisuje dane do Contextu, a Server Action powtarza pełną walidację na serwerze.

Architektura formularza multi-step: Context i walidacja Zod per krok

Code
// types/quote-form.ts
import { z } from 'zod'
 
// Schema per krok
export const step1Schema = z.object({
  name: z.string().min(2, 'Minimum 2 znaki'),
  email: z.email('Podaj poprawny email'), // Zod 4; na Zod 3 było z.string().email()
  company: z.string().optional(),
})
 
export const step2Schema = z.object({
  projectType: z.enum(['website', 'ecommerce', 'webapp', 'other']),
  budget: z.enum(['small', 'medium', 'large', 'enterprise']),
  deadline: z.string().min(1, 'Wybierz termin'),
})
 
export const step3Schema = z.object({
  description: z.string().min(20, 'Opisz projekt — minimum 20 znaków'),
  features: z.array(z.string()).min(1, 'Wybierz minimum 1 funkcjonalność'),
  referenceUrls: z.string().optional(),
})
 
// Pełna schema — do finalnej walidacji.
// Zod 4: łączymy schematy przez .extend() z ich .shape (.merge() jest wycofane).
export const fullSchema = step1Schema
  .extend(step2Schema.shape)
  .extend(step3Schema.shape)
 
export type QuoteFormData = z.infer<typeof fullSchema>
export type Step1Data = z.infer<typeof step1Schema>
export type Step2Data = z.infer<typeof step2Schema>
export type Step3Data = z.infer<typeof step3Schema>

React Context i stan formularza między krokami

to tu naturalny wybór: kroki są rodzeństwem renderowanym warunkowo, a stan musi być dla nich wspólny. Provider przechowuje dane, numer aktywnego kroku i funkcje nawigacji.

Code
// context/quote-form-context.tsx
'use client'
 
import { createContext, useContext, useState, useCallback } from 'react'
import type { QuoteFormData } from '@/types/quote-form'
 
interface QuoteFormContextValue {
  data: Partial<QuoteFormData>
  currentStep: number
  updateData: (stepData: Partial<QuoteFormData>) => void
  nextStep: () => void
  prevStep: () => void
  goToStep: (step: number) => void
  totalSteps: number
}
 
const QuoteFormContext = createContext<QuoteFormContextValue | null>(null)
 
export function useQuoteForm() {
  const ctx = useContext(QuoteFormContext)
  if (!ctx)
    throw new Error('useQuoteForm must be used within QuoteFormProvider')
  return ctx
}
 
export function QuoteFormProvider({ children }: { children: React.ReactNode }) {
  const [data, setData] = useState<Partial<QuoteFormData>>({})
  const [currentStep, setCurrentStep] = useState(1)
  const totalSteps = 4 // 3 kroki + podsumowanie
 
  const updateData = useCallback((stepData: Partial<QuoteFormData>) => {
    setData((prev) => ({ ...prev, ...stepData }))
  }, [])
 
  const nextStep = useCallback(() => {
    setCurrentStep((prev) => Math.min(prev + 1, totalSteps))
  }, [totalSteps])
 
  const prevStep = useCallback(() => {
    setCurrentStep((prev) => Math.max(prev - 1, 1))
  }, [])
 
  const goToStep = useCallback(
    (step: number) => {
      setCurrentStep(Math.max(1, Math.min(step, totalSteps)))
    },
    [totalSteps],
  )
 
  return (
    <QuoteFormContext.Provider
      value={{
        data,
        currentStep,
        updateData,
        nextStep,
        prevStep,
        goToStep,
        totalSteps,
      }}
    >
      {children}
    </QuoteFormContext.Provider>
  )
}

Zachowanie stanu formularza po odświeżeniu strony

Powyższy Provider trzyma dane w pamięci, więc przypadkowe odświeżenie (F5) kasuje wypełnione kroki — najczęstsza i najbardziej frustrująca wpadka multi-step formularzy. Rozwiązaniem jest cienka warstwa persistencji: inicjalizuj stan z sessionStorage, a przy każdej zmianie zapisuj go z powrotem (rozszerzamy QuoteFormProvider z poprzedniej sekcji o useEffect).

Code
const STORAGE_KEY = 'quote-form'
 
interface StoredState {
  data: Partial<QuoteFormData>
  step: number
}
 
function readStored(): StoredState {
  if (typeof window === 'undefined') return { data: {}, step: 1 }
  try {
    const parsed = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '{}')
    return { data: parsed.data ?? {}, step: parsed.step ?? 1 }
  } catch {
    return { data: {}, step: 1 }
  }
}
 
export function QuoteFormProvider({ children }: { children: React.ReactNode }) {
  const [stored] = useState(readStored)
  const [data, setData] = useState<Partial<QuoteFormData>>(stored.data)
  const [currentStep, setCurrentStep] = useState(stored.step)
 
  // Zapis przy każdej zmianie danych lub kroku
  useEffect(() => {
    sessionStorage.setItem(
      STORAGE_KEY,
      JSON.stringify({ data, step: currentStep }),
    )
  }, [data, currentStep])
 
  // ...reszta jak wyżej. Po udanym submicie wyczyść: sessionStorage.removeItem(STORAGE_KEY)
}

Zapisujemy dane i numer kroku razem — sam persist danych to połowa roboty, bo po odświeżeniu użytkownik z wypełnionymi trzema krokami wylądowałby z powrotem na pierwszym i musiałby przeklikać się przez całość, żeby wrócić tam, gdzie był.

Wybór sessionStorage (nie localStorage) jest celowy: dane żyją do zamknięcia karty, a nie w nieskończoność — przy formularzach z danymi osobowymi to bezpieczniejszy domyślny wybór. Pamiętaj, by wyczyścić storage po udanej wysyłce, żeby kolejny użytkownik tego samego urządzenia nie zobaczył cudzych danych.

Jedno zastrzeżenie hydratacyjne: skoro pierwszy render na serwerze nie zna zawartości sessionStorage, komponent formularza renderuj wyłącznie po stronie klienta (to i tak Client Component osadzony w serwerowej stronie), a jeśli prerenderujesz stronę statycznie — zadbaj, żeby odczyt storage nie różnicował pierwszego renderu, bo skończy się to .

Komponent główny jako router kroków formularza

Code
// components/quote-form/quote-form.tsx
'use client'
 
import { QuoteFormProvider, useQuoteForm } from '@/context/quote-form-context'
import { Step1Contact } from './step-1-contact'
import { Step2Project } from './step-2-project'
import { Step3Details } from './step-3-details'
import { Step4Summary } from './step-4-summary'
import { ProgressBar } from './progress-bar'
 
function FormSteps() {
  const { currentStep } = useQuoteForm()
 
  return (
    <div className="mx-auto max-w-2xl">
      <ProgressBar />
 
      <div className="mt-8">
        {currentStep === 1 && <Step1Contact />}
        {currentStep === 2 && <Step2Project />}
        {currentStep === 3 && <Step3Details />}
        {currentStep === 4 && <Step4Summary />}
      </div>
    </div>
  )
}
 
export function QuoteForm() {
  return (
    <QuoteFormProvider>
      <FormSteps />
    </QuoteFormProvider>
  )
}

Progress bar i nawigacja po ukończonych krokach

Code
// components/quote-form/progress-bar.tsx
'use client'
 
import { useQuoteForm } from '@/context/quote-form-context'
 
const stepLabels = ['Kontakt', 'Projekt', 'Szczegóły', 'Podsumowanie']
 
export function ProgressBar() {
  const { currentStep, totalSteps, goToStep } = useQuoteForm()
 
  return (
    <div className="flex items-center justify-between">
      {stepLabels.map((label, i) => {
        const step = i + 1
        const isActive = step === currentStep
        const isCompleted = step < currentStep
 
        return (
          <div key={label} className="flex items-center">
            <button
              onClick={() => isCompleted && goToStep(step)}
              disabled={!isCompleted}
              className={`flex items-center gap-2 ${
                isCompleted ? 'cursor-pointer' : 'cursor-default'
              }`}
            >
              <div
                className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
                  isActive
                    ? 'bg-blue-600 text-white'
                    : isCompleted
                      ? 'bg-green-500 text-white'
                      : 'bg-gray-200 text-gray-500'
                }`}
              >
                {isCompleted ? '✓' : step}
              </div>
              <span
                className={`hidden text-sm sm:block ${
                  isActive ? 'font-semibold' : 'text-gray-500'
                }`}
              >
                {label}
              </span>
            </button>
 
            {i < totalSteps - 1 && (
              <div
                className={`mx-2 h-0.5 w-12 ${
                  isCompleted ? 'bg-green-500' : 'bg-gray-200'
                }`}
              />
            )}
          </div>
        )
      })}
    </div>
  )
}

Krok formularza z walidacją Zod i FormData

Code
// components/quote-form/step-1-contact.tsx
'use client'
 
import { useQuoteForm } from '@/context/quote-form-context'
import { step1Schema, type Step1Data } from '@/types/quote-form'
import { useState } from 'react'
 
export function Step1Contact() {
  const { data, updateData, nextStep } = useQuoteForm()
  const [errors, setErrors] = useState<Record<string, string>>({})
 
  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)
 
    const stepData = {
      name: formData.get('name') as string,
      email: formData.get('email') as string,
      company: (formData.get('company') as string) || undefined,
    }
 
    const result = step1Schema.safeParse(stepData)
 
    if (!result.success) {
      const fieldErrors: Record<string, string> = {}
      result.error.issues.forEach((issue) => {
        fieldErrors[issue.path[0] as string] = issue.message
      })
      setErrors(fieldErrors)
      return
    }
 
    setErrors({})
    updateData(result.data)
    nextStep()
  }
 
  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <h2 className="text-xl font-semibold">Dane kontaktowe</h2>
 
      <div>
        <label htmlFor="name" className="mb-1 block text-sm font-medium">
          Imię i nazwisko *
        </label>
        <input
          id="name"
          name="name"
          defaultValue={data.name || ''}
          aria-invalid={!!errors.name}
          aria-describedby={errors.name ? 'name-error' : undefined}
          className="w-full rounded-lg border p-3"
        />
        {errors.name && (
          <p id="name-error" className="mt-1 text-sm text-red-500">
            {errors.name}
          </p>
        )}
      </div>
 
      <div>
        <label htmlFor="email" className="mb-1 block text-sm font-medium">
          Email *
        </label>
        <input
          id="email"
          name="email"
          type="email"
          defaultValue={data.email || ''}
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? 'email-error' : undefined}
          className="w-full rounded-lg border p-3"
        />
        {errors.email && (
          <p id="email-error" className="mt-1 text-sm text-red-500">
            {errors.email}
          </p>
        )}
      </div>
 
      <div>
        <label htmlFor="company" className="mb-1 block text-sm font-medium">
          Firma (opcjonalnie)
        </label>
        <input
          id="company"
          name="company"
          defaultValue={data.company || ''}
          className="w-full rounded-lg border p-3"
        />
      </div>
 
      <button
        type="submit"
        className="w-full rounded-lg bg-blue-600 py-3 text-white"
      >
        Dalej →
      </button>
    </form>
  )
}

Trzy detale w tym kodzie robią różnicę dla czytników ekranu: htmlFor/id wiąże etykietę z polem (klik w etykietę fokusuje pole, a czytnik wie, co ogłosić), aria-invalid oznacza pole z błędem, a aria-describedby sprawia, że komunikat błędu jest odczytywany razem z polem — nie jest tylko czerwonym tekstem gdzieś obok.

Dostępność: fokus musi podążać za krokiem

Multi-step formularz ma problem, którego nie ma zwykły formularz: po kliknięciu „Dalej" wizualnie zmienia się wszystko, a dla czytnika ekranu — nic. Fokus zostaje na przycisku, który właśnie zniknął z kontekstu, i użytkownik nie wie, że jest w nowym kroku. Rozwiązanie to przeniesienie fokusu na nagłówek świeżo zamontowanego kroku:

Code
// components/quote-form/step-heading.tsx
'use client'
 
import { useEffect, useRef } from 'react'
 
export function StepHeading({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLHeadingElement>(null)
 
  // Krok montuje się na nowo przy każdej zmianie — fokus ląduje na nagłówku,
  // czytnik ekranu ogłasza tytuł kroku
  useEffect(() => {
    ref.current?.focus()
  }, [])
 
  return (
    <h2 ref={ref} tabIndex={-1} className="text-xl font-semibold outline-none">
      {children}
    </h2>
  )
}

Zamień <h2> w każdym kroku na <StepHeading> i nawigacja klawiaturą zaczyna mieć sens: Tab z nagłówka prowadzi do pierwszego pola nowego kroku. W progress barze dodaj do aktywnego elementu aria-current="step" — czytniki ogłoszą wtedy „krok 2 z 4, Projekt" zamiast anonimowej listy przycisków. Zauważ też, że disabled na przyszłych krokach w progress barze już chroni przed przeskakiwaniem walidacji — stan interfejsu i stan formularza mówią jednym głosem.

Podsumowanie i wysyłka formularza przez Server Action

Ostatni krok pokazuje zebrane dane i wysyła je przez . Walidację fullSchema powtarzamy tu po stronie serwera — to ostatnia i jedyna wiążąca linia obrony, niezależnie od tego, co przeszło na kliencie. Tę serwerową walidację i jej efekty uboczne warto pokryć testami, bo to ona realnie chroni dane. Jeśli budujesz nowy formularz od zera, rozważ też React 19 Actions z useActionState — upraszczają obsługę stanu wysyłki i błędów względem ręcznego useState.

Code
// components/quote-form/step-4-summary.tsx
'use client'
 
import { useQuoteForm } from '@/context/quote-form-context'
import { submitQuoteForm } from '@/actions/quote'
import { fullSchema } from '@/types/quote-form'
import { useState } from 'react'
 
export function Step4Summary() {
  const { data, prevStep } = useQuoteForm()
  const [isSubmitting, setIsSubmitting] = useState(false)
  const [result, setResult] = useState<{
    success?: boolean
    error?: string
  } | null>(null)
 
  async function handleSubmit() {
    const validated = fullSchema.safeParse(data)
    if (!validated.success) {
      setResult({ error: 'Formularz zawiera błędy. Wróć i popraw dane.' })
      return
    }
 
    setIsSubmitting(true)
    const response = await submitQuoteForm(validated.data)
    setResult(response)
    setIsSubmitting(false)
  }
 
  if (result?.success) {
    return (
      <div className="py-12 text-center">
        <h2 className="text-2xl font-bold text-green-600">Dziękujemy!</h2>
        <p className="mt-2 text-gray-600">Odpowiemy w ciągu 24 godzin.</p>
      </div>
    )
  }
 
  return (
    <div className="space-y-6">
      <h2 className="text-xl font-semibold">Podsumowanie</h2>
 
      <div className="space-y-3 rounded-lg bg-gray-50 p-4">
        <SummaryRow label="Imię" value={data.name} />
        <SummaryRow label="Email" value={data.email} />
        <SummaryRow label="Typ projektu" value={data.projectType} />
        <SummaryRow label="Budżet" value={data.budget} />
        <SummaryRow label="Opis" value={data.description} />
      </div>
 
      {result?.error && <p className="text-red-500">{result.error}</p>}
 
      <div className="flex gap-3">
        <button onClick={prevStep} className="flex-1 rounded-lg border py-3">
          ← Wstecz
        </button>
        <button
          onClick={handleSubmit}
          disabled={isSubmitting}
          className="flex-1 rounded-lg bg-blue-600 py-3 text-white disabled:opacity-50"
        >
          {isSubmitting ? 'Wysyłanie...' : 'Wyślij zapytanie'}
        </button>
      </div>
    </div>
  )
}
 
function SummaryRow({ label, value }: { label: string; value?: string }) {
  return (
    <div className="flex justify-between">
      <span className="text-gray-500">{label}</span>
      <span className="font-medium">{value || '—'}</span>
    </div>
  )
}

A sama akcja wygląda tak — zwróć uwagę, że walidacja na kliencie w Step4Summary niczego tu nie zmienia, serwer waliduje od zera:

Code
// actions/quote.ts
'use server'
 
import { fullSchema } from '@/types/quote-form'
import { saveQuoteRequest } from '@/data/quotes'
import { sendNotificationEmail } from '@/lib/email'
 
export async function submitQuoteForm(input: unknown) {
  // `unknown`, nie `QuoteFormData` — typ z klienta to deklaracja,
  // nie gwarancja. Server Action to publiczny endpoint HTTP.
  const parsed = fullSchema.safeParse(input)
 
  if (!parsed.success) {
    return { success: false, error: 'Dane formularza są niepoprawne.' }
  }
 
  try {
    await saveQuoteRequest(parsed.data)
    await sendNotificationEmail(parsed.data)
    return { success: true }
  } catch {
    // Szczegóły zostają w logach serwera, nie w odpowiedzi
    return { success: false, error: 'Nie udało się wysłać. Spróbuj ponownie.' }
  }
}

Dwa detale są tu nieprzypadkowe. Parametr ma typ unknown, mimo że klient wysyła zwalidowane QuoteFormData — bo Server Action można wywołać bezpośrednio, z dowolnym payloadem, i typ TypeScriptu na granicy sieci jest tylko życzeniem. I druga rzecz: catch zwraca ogólny komunikat, a szczegóły błędu zostają w logach — odpowiedź akcji trafia do przeglądarki, więc nie powinna zdradzać wnętrza systemu. Formularz publiczny warto też osłonić przed botami (honeypot, rate limiting per IP) — wzorce testowania takich akcji obejmują i te ścieżki.

Na koniec pomiar: multi-step ma naturalny lejek, więc wysyłaj event analityczny przy każdym przejściu kroku (np. quote_form_step_completed z numerem kroku w nextStep()). Po tygodniu zobaczysz, na którym kroku użytkownicy odpadają — to zwykle konkretne pole, które można uprościć albo przenieść dalej, a nie „formularz jest za długi" jako całość.

Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
Next.js

Często zadawane pytania

Jak zachować stan formularza po odświeżeniu strony?

Dodaj warstwę persist do Contextu, zapisuj dane i numer aktywnego kroku do sessionStorage przy każdej zmianie, a przy inicjalizacji wczytuj je z powrotem. Dzięki temu przypadkowe odświeżenie (F5) nie kasuje wypełnionych kroków ani nie cofa użytkownika na początek. Po udanym submicie wyczyść storage, żeby dane nie zostały na współdzielonym urządzeniu.

Jak zadbać o dostępność formularza wieloetapowego?

Cztery rzeczy robią największą różnicę: po zmianie kroku przenieś fokus na nagłówek nowego kroku (tabIndex={-1} + focus()), żeby czytnik ekranu ogłosił zmianę; każde pole powiąż z etykietą przez htmlFor/id; błędy wskaż przez aria-invalid i aria-describedby na polu; a progress bar zbuduj jako listę z aria-current="step" na aktywnym elemencie. Bez tego użytkownik czytnika po kliknięciu „Dalej" nie wie, że cokolwiek się zmieniło.

Ile kroków to za dużo?

3–5 kroków to optimum. Powyżej 5 użytkownik traci orientację. Jeśli formularz ma 30 pól, grupuj je logicznie (dane osobowe, projekt, preferencje), a nie mechanicznie (5 pól = 1 krok) — krok powinien odpowiadać jednemu spójnemu etapowi decyzji.

Czy URL powinien zmieniać się między krokami?

Opcjonalnie, np. /wycena?step=2. Daje to deep linking i działający przycisk „wstecz" w przeglądarce, ale dodaje złożoność — trzeba synchronizować searchParams ze stanem w Context. Dla krótkich formularzy zysk zwykle nie jest wart tej komplikacji.

Dlaczego niekontrolowane pola, a nie value + onChange?

Pola niekontrolowane z defaultValue nie wywołują re-renderu przy każdym wciśnięciu klawisza — wartości zbiera FormData dopiero przy submicie kroku. Dla długich formularzy to zauważalnie mniej pracy dla React. Kontrolowane pola wybieraj, gdy potrzebujesz live walidacji albo zależności między polami.

Czy walidacja po stronie klienta wystarczy?

Nie. Walidacja kliencka per krok to kwestia UX — natychmiastowy feedback. Zawsze powtórz pełną walidację (fullSchema.safeParse) w Server Action przed zapisem, bo żądanie można wysłać z pominięciem interfejsu. Klient nigdy nie jest źródłem prawdy o poprawności danych.

O autorze

Maciej Sala

Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwijam własne projekty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
React 19 Actions — formularz bez onSubmit, useOptimistic i useActionState w praktyce

React 19 pozwala przekazać funkcję bezpośrednio do atrybutu action elementu . Dzięki integracji z Actions, useActionState , useFormStatus i useOptimistic możesz ograniczyć ręczną obsługę onSubmit , pending state oraz optymistycznych aktualizacji , zachowując kontrolę nad błędami i stanem trwałym.

Maciej Sala

Maciej Sala

Founder StriveLab

Jak bezpiecznie obsługiwać formularze w Astro?

Klasyczny formularz bardzo szybko robi się ciężki przez takie elementy jak endpoint, odczytywanie ciała żądania, walidacja, statusy HTTP, fetch po stronie klienta i typy przepisane drugi raz. Na ratunek przybywają Astro Actions: wystarczy, że zdefiniujesz logikę funkcji serwerowej, dodasz schemat Zod i wywołasz ją z klienta bez ręcznego składania REST API.

Maciej Sala

Maciej Sala

Founder StriveLab

Jak React Server Components wpływają na SEO i performance?

Przez lata React pchał, coraz więcej i więcej pracy do przeglądarki, w efekcie czego mamy większe bundle, hydratacja trwa dłużej, są puste loadingi i strony, które bez JavaScriptu straciły sens. App Router odwraca ten kierunek. React Server Components pozwalają renderować treść na serwerze bez wysyłania całej logiki do klienta, a Server Actions upraszczają formularze i mutacje. To ma znaczenie dla SEO, bo crawler szybciej dostaje HTML. Ma też znaczenie dla biznesu, bo użytkownik szybciej widzi i dostaje to, po co przyszedł.

Maciej Sala

Maciej Sala

Founder StriveLab