React Hook Form + Zod w Next.js — walidacja formularzy z Server Actions

Jak zbudować formularz w Next.js z React Hook Form, Zod i Server Actions? Jeden schemat walidacji, błędy client/server, typowanie i progressive enhancement.

Opublikowano

11 kwietnia 2026 11:10

Czytanie

3 min czytania

Aktualizacja

15 kwietnia 2026 11:52

Dlaczego ten stack?

React Hook Form — wydajne formularze (minimalne re-rendery), wbudowany error management, integracja z UI bibliotekami. Zod — walidacja z type inference (jeden schemat → typ TypeScript). Server Actions — mutacje na serwerze bez API endpoints.

Ten wpis jest praktyczną implementacją formularza na konkretnym stacku. Jeśli chcesz najpierw zrozumieć same Server Actions i ich trade-offy, zacznij od Server Actions — formularze bez endpointów API.

Razem: walidacja na kliencie (natychmiastowy feedback) + walidacja na serwerze (bezpieczeństwo) + jeden schemat Zod (DRY).

Setup

Code
npm install react-hook-form @hookform/resolvers zod

Schemat Zod — jedno źródło prawdy

Code
// lib/schemas/contact.ts
import { z } from 'zod';
 
export const contactSchema = z.object({
  name: z.string()
    .min(2, 'Imię musi mieć minimum 2 znaki')
    .max(100, 'Imię może mieć maksymalnie 100 znaków')
    .trim(),
  email: z.string()
    .email('Podaj poprawny adres email')
    .toLowerCase(),
  phone: z.string()
    .regex(/^(\+48)?[\s-]?\d{3}[\s-]?\d{3}[\s-]?\d{3}$/, 'Podaj poprawny numer telefonu')
    .optional()
    .or(z.literal('')),
  projectType: z.enum(['website', 'ecommerce', 'webapp', 'other'], {
    errorMap: () => ({ message: 'Wybierz typ projektu' }),
  }),
  budget: z.enum(['small', 'medium', 'large']),
  message: z.string()
    .min(10, 'Wiadomość musi mieć minimum 10 znaków')
    .max(5000, 'Wiadomość może mieć maksymalnie 5000 znaków'),
  consent: z.literal(true, {
    errorMap: () => ({ message: 'Musisz zaakceptować politykę prywatności' }),
  }),
});
 
// Typ inferowany ze schematu — zero duplikacji
export type ContactFormData = z.infer<typeof contactSchema>;

Ten sam schemat używany jest na kliencie (React Hook Form resolver) i serwerze (Server Action).

Server Action — walidacja serwerowa

Code
// actions/contact.ts
'use server';
 
import { contactSchema, type ContactFormData } from '@/lib/schemas/contact';
import { resend } from '@/lib/resend';
 
export type ContactActionResult =
  | { success: true }
  | { success: false; errors: Record<string, string[]> };
 
export async function submitContact(data: ContactFormData): Promise<ContactActionResult> {
  // Walidacja serwerowa — NIE ufaj klientowi
  const parsed = contactSchema.safeParse(data);
 
  if (!parsed.success) {
    return {
      success: false,
      errors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
    };
  }
 
  try {
    await resend.emails.send({
      from: 'StriveLab <kontakt@strivelab.pl>',
      to: 'maciej@strivelab.pl',
      subject: `Nowe zapytanie: ${parsed.data.projectType}${parsed.data.name}`,
      html: `
        <p><strong>Od:</strong> ${parsed.data.name} (${parsed.data.email})</p>
        <p><strong>Telefon:</strong> ${parsed.data.phone || 'Nie podano'}</p>
        <p><strong>Typ:</strong> ${parsed.data.projectType}</p>
        <p><strong>Budżet:</strong> ${parsed.data.budget}</p>
        <p><strong>Wiadomość:</strong></p>
        <p>${parsed.data.message}</p>
      `,
    });
 
    return { success: true };
  } catch {
    return {
      success: false,
      errors: { _form: ['Nie udało się wysłać wiadomości. Spróbuj ponownie.'] },
    };
  }
}

Formularz — React Hook Form + zodResolver

Code
// components/contact-form.tsx
'use client';
 
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { contactSchema, type ContactFormData } from '@/lib/schemas/contact';
import { submitContact, type ContactActionResult } from '@/actions/contact';
import { useState } from 'react';
 
export function ContactForm() {
  const [serverResult, setServerResult] = useState<ContactActionResult | null>(null);
 
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    reset,
    setError,
  } = useForm<ContactFormData>({
    resolver: zodResolver(contactSchema),
    defaultValues: {
      name: '',
      email: '',
      phone: '',
      projectType: undefined,
      budget: 'medium',
      message: '',
      consent: false as unknown as true,
    },
  });
 
  async function onSubmit(data: ContactFormData) {
    const result = await submitContact(data);
 
    if (result.success) {
      setServerResult(result);
      reset();
      return;
    }
 
    // Mapuj błędy serwerowe na pola RHF
    if (result.errors) {
      for (const [field, messages] of Object.entries(result.errors)) {
        if (field === '_form') continue;
        setError(field as keyof ContactFormData, {
          message: messages[0],
        });
      }
    }
 
    setServerResult(result);
  }
 
  if (serverResult?.success) {
    return (
      <div className="text-center py-12 bg-green-50 rounded-xl">
        <h3 className="text-xl font-semibold text-green-700">Dziękujemy!</h3>
        <p className="text-green-600 mt-2">Odpowiemy w ciągu 24 godzin.</p>
        <button
          onClick={() => setServerResult(null)}
          className="mt-4 text-green-700 underline"
        >
          Wyślij kolejną wiadomość
        </button>
      </div>
    );
  }
 
  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-5" noValidate>
      {/* Imię */}
      <FormField label="Imię i nazwisko" error={errors.name?.message} required>
        <input {...register('name')} className={inputClass(errors.name)} />
      </FormField>
 
      {/* Email */}
      <FormField label="Email" error={errors.email?.message} required>
        <input {...register('email')} type="email" className={inputClass(errors.email)} />
      </FormField>
 
      {/* Telefon */}
      <FormField label="Telefon" error={errors.phone?.message}>
        <input {...register('phone')} type="tel" placeholder="+48 XXX XXX XXX" className={inputClass(errors.phone)} />
      </FormField>
 
      {/* Typ projektu */}
      <FormField label="Typ projektu" error={errors.projectType?.message} required>
        <select {...register('projectType')} className={inputClass(errors.projectType)}>
          <option value="">Wybierz...</option>
          <option value="website">Strona internetowa</option>
          <option value="ecommerce">Sklep online</option>
          <option value="webapp">Aplikacja webowa</option>
          <option value="other">Inne</option>
        </select>
      </FormField>
 
      {/* Budżet */}
      <FormField label="Budżet" error={errors.budget?.message} required>
        <div className="flex gap-3">
          {[
            { value: 'small', label: 'Do 5K PLN' },
            { value: 'medium', label: '5K–15K PLN' },
            { value: 'large', label: '15K+ PLN' },
          ].map((option) => (
            <label key={option.value} className="flex items-center gap-2 cursor-pointer">
              <input {...register('budget')} type="radio" value={option.value} />
              <span className="text-sm">{option.label}</span>
            </label>
          ))}
        </div>
      </FormField>
 
      {/* Wiadomość */}
      <FormField label="Wiadomość" error={errors.message?.message} required>
        <textarea {...register('message')} rows={5} className={inputClass(errors.message)} />
      </FormField>
 
      {/* Zgoda */}
      <div>
        <label className="flex items-start gap-2 cursor-pointer">
          <input {...register('consent')} type="checkbox" className="mt-1" />
          <span className="text-sm text-gray-600">
            Akceptuję <a href="/polityka-prywatnosci" className="text-blue-600 underline">politykę prywatności</a> *
          </span>
        </label>
        {errors.consent && <p className="text-sm text-red-600 mt-1">{errors.consent.message}</p>}
      </div>
 
      {/* Błąd globalny */}
      {serverResult && !serverResult.success && serverResult.errors?._form && (
        <p className="text-red-600 bg-red-50 p-3 rounded-lg">{serverResult.errors._form[0]}</p>
      )}
 
      {/* Submit */}
      <button
        type="submit"
        disabled={isSubmitting}
        className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium disabled:opacity-50"
      >
        {isSubmitting ? 'Wysyłanie...' : 'Wyślij zapytanie'}
      </button>
    </form>
  );
}
 
// Helper components
function FormField({ label, error, required, children }: {
  label: string;
  error?: string;
  required?: boolean;
  children: React.ReactNode;
}) {
  return (
    <div>
      <label className="block text-sm font-medium mb-1">
        {label} {required && <span className="text-red-500">*</span>}
      </label>
      {children}
      {error && <p className="text-sm text-red-600 mt-1">{error}</p>}
    </div>
  );
}
 
function inputClass(error?: { message?: string }) {
  return `w-full border rounded-lg p-3 ${error ? 'border-red-500' : 'border-gray-300'} focus:ring-2 focus:ring-blue-500 focus:border-transparent`;
}

Przepływ danych

Code
1. Użytkownik wypełnia formularz
2. React Hook Form + zodResolver waliduje na kliencie (natychmiastowy feedback)
3. Po poprawnej walidacji → handleSubmit wywołuje onSubmit
4. onSubmit wywołuje Server Action z typowanymi danymi
5. Server Action waliduje ponownie Zod (bezpieczeństwo)
6. Jeśli OK → wysyła email, zwraca { success: true }
7. Jeśli błąd serwerowy → zwraca errors → setError mapuje na pola RHF

Dwie warstwy walidacji: klient (UX — natychmiastowy feedback), serwer (bezpieczeństwo — nie ufaj klientowi). Jeden schemat Zod obsługuje oba.

Podsumowanie

React Hook Form + Zod + Server Actions to najlepszy stack do formularzy w Next.js App Router. Jeden schemat Zod = jedno źródło prawdy dla walidacji, typów i error messages. RHF daje wydajne formularze z minimalnym re-renderem. Server Actions eliminują potrzebę API endpoints.

Kluczowe: zawsze waliduj na serwerze (Server Action), nawet gdy klient waliduje. Mapuj błędy serwerowe na pola RHF przez setError. I pamiętaj o noValidate na <form> — żeby przeglądarka nie wyświetlała własnych tooltipów walidacyjnych.

Najczęściej zadawane pytania

Czy mogę użyć Server Actions bez React Hook Form?

Tak — useActionState + natywne FormData wystarczą dla prostych formularzy (3–5 pól). RHF dodaje wartość przy złożonych formularzach: dynamiczne pola, wielokrokowe, walidacja per-keystroke.

Jak obsłużyć file upload w RHF + Server Actions?

RHF obsługuje <input type="file"> z register('file'). W Server Action odbierasz plik jako FormData.get('file'). Ale lepiej: użyj Uploadthing z dedykowanym komponentem uploadu, a w formularzu przekaż URL przesłanego pliku.

Czy zodResolver spowalnia formularz?

Nie zauważalnie — Zod waliduje schemat w < 1 ms. RHF domyślnie waliduje onSubmit; dla per-keystroke dodaj mode: 'onChange' w useForm (lekko cięższe, ale nadal szybkie).

Pracuję z tym zawodowo.

Jeśli chcesz przełożyć ten temat na lepszą architekturę frontendu, uporządkować React lub Next.js i podnieść jakość pracy zespołu, skontaktuj się ze mną. Pomagam zamieniać wiedzę z artykułów w praktyczne decyzje technologiczne.

O autorze

Maciej Sala

Maciej Sala — project manager i frontendowiec z doświadczeniem w marketingu internetowym. Na co dzień pracuję z Reactem, Next.js i TypeScriptem, łącząc perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat związany z branżą gier wideo jako project manager i game designer.

Absolwent historii na Uniwersytecie Jagiellońskim i studiów podyplomowych z marketingu internetowego na Akademii Górniczo-Hutniczej w Krakowie. Poza pracą trenuje na siłowni, maluje figurki i realizuje własne projekty.

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów
Anthropic uderza w Figmę i Adobe — oto Claude Design

Anthropic uderza w Figmę i Adobe — oto Claude Design

Anthropic wypuścił właśnie narzędzie AI do tworzenia stron, landing page'ów i prezentacji z promptu. Oto co wiemy o Claude Design i Opus 4.7 — i co to oznacza dla developerów.

Maciej Sala

Maciej Sala

Founder Strivelab

Astro.js vs Next.js — które narzędzie wybrać w 2026 roku?

Astro.js vs Next.js — które narzędzie wybrać w 2026 roku?

Fachowe porównanie Astro.js i Next.js z perspektywy developera pracującego na co dzień w Next.js. Architektura, wydajność, SEO, DX, koszty i konkretne use case — z benchmarkami i przykładami kodu.

Maciej Sala

Maciej Sala

Founder Strivelab