Server Actions у Next.js: безпечна валідація форм із Zod та useActionState

Як створити безпечні форми в Next.js App Router: Zod-валідація, useActionState, автентифікація, авторизація та next-safe-action — з прикладами коду для продакшну.

Якщо ви зараз будуєте форми в Next.js App Router, то Server Actions — ваш головний інструмент для серверної обробки даних. Вони замінюють окремі API-маршрути, спрощують архітектуру і підтримують прогресивне покращення з коробки. Звучить чудово, правда?

Але є один момент, який дуже легко проґавити: кожна Server Action — це публічний HTTP-ендпоінт. Без валідації, автентифікації та авторизації ваш додаток фактично відкритий для будь-кого.

У цій статті ми розберемо повний цикл роботи з формами — від Zod-схем до обробки помилок через useActionState. І, що важливо, покриємо безпекові практики, без яких у продакшн краще не йти.

Що таке Server Actions і чому безпека — не опція

Server Actions — це асинхронні функції з директивою 'use server', які виконуються на сервері, але викликаються прямо з компонентів. Next.js автоматично створює POST-ендпоінт для кожної такої функції та бере на себе мережеві запити.

І ось тут починається цікаве.

Server Actions — це публічні HTTP POST ендпоінти. Навіть якщо функція не імпортується жодним компонентом у вашому коді, вона все одно доступна ззовні. TypeScript-типи зникають у рантаймі — ваша анотація userId: string не завадить зловмиснику надіслати щось на кшталт {"userId": {"$ne": null}}. Чесно кажучи, я сам колись думав, що «ну, це ж внутрішня функція, хто її знайде?» — і був неправий.

У грудні 2025 року була виявлена критична вразливість CVE-2025-55182 (CVSS 10.0), яка дозволяла віддалене виконання коду через десеріалізацію даних у Server Actions. Це зайвий раз підтверджує — валідація та безпека мають бути частиною кожної серверної функції, а не «додамо потім».

Створення Zod-схеми валідації

Zod — бібліотека для TypeScript-first валідації, яка ідеально лягає на Server Actions. Визначаєте схему один раз — використовуєте і на сервері для захисту, і на клієнті для UX. Менше дублювання, менше головного болю.

Базова схема для контактної форми

// lib/schemas.ts
import { z } from 'zod'

export const contactFormSchema = z.object({
  name: z
    .string()
    .min(2, 'Ім\'я повинно містити щонайменше 2 символи')
    .max(100, 'Ім\'я занадто довге'),
  email: z
    .string()
    .email('Введіть коректну email-адресу'),
  message: z
    .string()
    .min(10, 'Повідомлення повинно містити щонайменше 10 символів')
    .max(1000, 'Повідомлення занадто довге'),
})

export type ContactFormData = z.infer<typeof contactFormSchema>

Зверніть увагу: схема лежить в окремому файлі без директиви 'use server'. Це дає змогу імпортувати її і в серверних, і в клієнтських компонентах. Ніякого дублювання коду.

Складніша схема з кастомними правилами

// lib/schemas.ts
export const registrationSchema = z.object({
  username: z
    .string()
    .min(3, 'Мінімум 3 символи')
    .max(30, 'Максимум 30 символів')
    .regex(/^[a-zA-Z0-9_]+$/, 'Лише літери, цифри та підкреслення'),
  email: z
    .string()
    .email('Некоректний email'),
  password: z
    .string()
    .min(8, 'Мінімум 8 символів')
    .regex(/[A-Z]/, 'Потрібна хоча б одна велика літера')
    .regex(/[0-9]/, 'Потрібна хоча б одна цифра'),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: 'Паролі не збігаються',
  path: ['confirmPassword'],
})

Метод .refine() — це крос-польова валідація. Наприклад, перевірка, що паролі збігаються. Помилка автоматично прив'язується до потрібного поля через параметр path. Дуже зручно, бо не треба писати окрему логіку для цього.

Написання безпечної Server Action

Тепер найцікавіше — створюємо Server Action з Zod-валідацією. Тут є один нюанс, який ловить багатьох: коли ви підключаєте useActionState на клієнті, сигнатура серверної функції змінюється — перший аргумент стає попереднім станом, а не FormData.

// app/actions/contact.ts
'use server'

import { contactFormSchema } from '@/lib/schemas'
import { revalidatePath } from 'next/cache'

export type ContactFormState = {
  errors?: {
    name?: string[]
    email?: string[]
    message?: string[]
  }
  message?: string | null
  success?: boolean
}

export async function submitContactForm(
  prevState: ContactFormState,
  formData: FormData
): Promise<ContactFormState> {
  // 1. Валідація вхідних даних
  const validatedFields = contactFormSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  })

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Будь ласка, виправте помилки у формі.',
      success: false,
    }
  }

  // 2. Бізнес-логіка (збереження в БД, надсилання email тощо)
  try {
    await saveContactMessage(validatedFields.data)

    revalidatePath('/contacts')

    return {
      message: 'Повідомлення успішно надіслано!',
      success: true,
    }
  } catch (error) {
    return {
      message: 'Щось пішло не так. Спробуйте пізніше.',
      success: false,
    }
  }
}

Декілька моментів, на які варто звернути увагу:

  • safeParse() замість parse() — не кидає виключення, а повертає об'єкт з прапорцем success. Набагато зручніше для обробки помилок у формах.
  • .flatten().fieldErrors — перетворює Zod-помилки у формат { fieldName: string[] }, який легко прив'язати до конкретних полів.
  • Повертаємо помилки як частину відповіді, а не кидаємо виключення. Це дає повний контроль над відображенням на клієнті.
  • Тип ContactFormState має однакову структуру для помилок і успіху — це вимога TypeScript при роботі з useActionState.

Підключення useActionState у клієнтському компоненті

useActionState — хук із React 19, який прийшов на заміну застарілому useFormState з react-dom. Повертає три речі: поточний стан, функцію-диспетчер і прапорець pending.

// components/ContactForm.tsx
'use client'

import { useActionState } from 'react'
import { submitContactForm, type ContactFormState } from '@/app/actions/contact'

const initialState: ContactFormState = {
  errors: {},
  message: null,
  success: false,
}

export function ContactForm() {
  const [state, formAction, pending] = useActionState(
    submitContactForm,
    initialState
  )

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="name">Ім'я</label>
        <input
          id="name"
          name="name"
          type="text"
          aria-describedby="name-error"
        />
        {state.errors?.name && (
          <p id="name-error" className="text-red-500 text-sm">
            {state.errors.name[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          aria-describedby="email-error"
        />
        {state.errors?.email && (
          <p id="email-error" className="text-red-500 text-sm">
            {state.errors.email[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="message">Повідомлення</label>
        <textarea
          id="message"
          name="message"
          rows={4}
          aria-describedby="message-error"
        />
        {state.errors?.message && (
          <p id="message-error" className="text-red-500 text-sm">
            {state.errors.message[0]}
          </p>
        )}
      </div>

      <button type="submit" disabled={pending}>
        {pending ? 'Надсилання...' : 'Надіслати'}
      </button>

      {state.message && (
        <p className={state.success ? 'text-green-600' : 'text-red-600'}>
          {state.message}
        </p>
      )}
    </form>
  )
}

Що тут класно — форма працює навіть без JavaScript. Прогресивне покращення з коробки: дані відправляються через звичайний POST. А атрибути aria-describedby роблять форму доступною для скрінрідерів.

Виділення кнопки з useFormStatus

Інколи потрібно винести кнопку сабміту в окремий компонент (наприклад, для перевикористання). Для цього є хук useFormStatus з react-dom. Єдина умова — компонент з цим хуком має бути дочірнім елементом <form>.

// components/SubmitButton.tsx
'use client'

import { useFormStatus } from 'react-dom'

export function SubmitButton({ label = 'Надіслати' }: { label?: string }) {
  const { pending } = useFormStatus()

  return (
    <button
      type="submit"
      disabled={pending}
      className={pending ? 'opacity-50 cursor-wait' : ''}
    >
      {pending ? 'Обробка...' : label}
    </button>
  )
}

Просто замініть звичайний <button> на <SubmitButton /> у вашій формі. Невелика, але важлива деталь: useFormStatus і useActionState не можна використовувати в одному компоненті — тому кнопку і виносимо окремо.

Автентифікація та авторизація в Server Actions

Ось помилка, яку я бачу постійно: розробник перевіряє сесію на сторінці, але повністю забуває зробити те ж саме в самій Server Action. А навіщо, думає він, якщо сторінка і так захищена?

Проблема в тому, що дію можна викликати напряму через POST-запит, повністю обійшовши інтерфейс. Тому перевірка потрібна в кожній дії, без винятків.

// app/actions/posts.ts
'use server'

import { auth } from '@/lib/auth'
import { z } from 'zod'
import { revalidateTag } from 'next/cache'

const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  categoryId: z.string().uuid(),
})

export async function createPost(prevState: any, formData: FormData) {
  // 1. Автентифікація — хто це?
  const session = await auth()
  if (!session?.user) {
    return { message: 'Необхідно увійти в систему.', success: false }
  }

  // 2. Валідація вхідних даних
  const validated = createPostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
    categoryId: formData.get('categoryId'),
  })

  if (!validated.success) {
    return {
      errors: validated.error.flatten().fieldErrors,
      success: false,
    }
  }

  // 3. Авторизація — чи має право?
  const userRole = session.user.role
  if (userRole !== 'admin' && userRole !== 'editor') {
    return { message: 'Недостатньо прав для створення публікації.', success: false }
  }

  // 4. Виконання мутації
  try {
    await db.insert(posts).values({
      ...validated.data,
      authorId: session.user.id,
    })

    revalidateTag('posts', 'max')
    return { message: 'Публікацію створено!', success: true }
  } catch (error) {
    return { message: 'Помилка при збереженні.', success: false }
  }
}

Порядок перевірок тут принциповий:

  1. Автентифікація — чи користувач взагалі увійшов?
  2. Валідація — чи дані коректні?
  3. Авторизація — чи має він право це робити?
  4. Мутація — і тільки тоді виконуємо дію

Саме в такій послідовності. Не навпаки.

next-safe-action: менше шаблонного коду, більше безпеки

Якщо ви пишете продакшн-додаток з десятками Server Actions, шаблонний код починає набридати. Перевірка сесії, валідація, типізація — одне й те саме в кожній функції. Бібліотека next-safe-action вирішує саме цю проблему.

Вона автоматизує валідацію через Zod, підтримує middleware для автентифікації і забезпечує повну типобезпеку.

// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action'
import { auth } from '@/lib/auth'

// Базовий клієнт — для публічних дій
export const actionClient = createSafeActionClient()

// Автентифікований клієнт — для захищених дій
export const authActionClient = createSafeActionClient({
  async middleware() {
    const session = await auth()
    if (!session?.user) {
      throw new Error('Unauthorized')
    }
    return { user: session.user }
  },
})
// app/actions/posts.ts
'use server'

import { authActionClient } from '@/lib/safe-action'
import { z } from 'zod'

export const createPost = authActionClient
  .schema(z.object({
    title: z.string().min(1).max(200),
    content: z.string().min(1),
  }))
  .action(async ({ parsedInput, ctx }) => {
    // ctx.user вже перевірений і типізований
    await db.insert(posts).values({
      ...parsedInput,
      authorId: ctx.user.id,
    })

    return { message: 'Публікацію створено!' }
  })

Бачите різницю? Замість 40+ рядків шаблонного коду — лаконічний ланцюжок викликів. А на клієнті використовуємо хук useAction:

// components/CreatePostForm.tsx
'use client'

import { useAction } from 'next-safe-action/hooks'
import { createPost } from '@/app/actions/posts'

export function CreatePostForm() {
  const { execute, result, isExecuting } = useAction(createPost)

  return (
    <form onSubmit={(e) => {
      e.preventDefault()
      const formData = new FormData(e.currentTarget)
      execute({
        title: formData.get('title') as string,
        content: formData.get('content') as string,
      })
    }}>
      <input name="title" placeholder="Заголовок" />
      {result.validationErrors?.title && (
        <p className="text-red-500">{result.validationErrors.title[0]}</p>
      )}
      <textarea name="content" placeholder="Вміст" />
      <button disabled={isExecuting}>
        {isExecuting ? 'Збереження...' : 'Створити'}
      </button>
    </form>
  )
}

Бібліотека стабільно працює з Next.js 16.1 та React 19 і готова для продакшну.

Ревалідація кешу після мутацій

Ви зберегли дані — тепер треба оновити кеш, інакше користувач побачить старе. Next.js пропонує три інструменти для цього:

  • revalidatePath('/posts') — інвалідує конкретний маршрут. Підходить, коли зміни впливають на одну сторінку.
  • revalidateTag('posts', 'max') — інвалідує всі дані з цим тегом. Другий аргумент 'max' вмикає stale-while-revalidate — поточний запит отримає старі дані, наступний — свіжі.
  • updateTag('posts') — нова функція, доступна лише в Server Actions. Негайно інвалідує кеш для сценарію read-your-own-writes. Користувач одразу бачить результат своєї дії.

Важливий момент: якщо після мутації потрібен редирект, завжди викликайте ревалідацію до redirect():

'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createPost(prevState: any, formData: FormData) {
  // ... валідація та збереження ...
  revalidatePath('/posts')
  redirect('/posts')  // завжди після revalidate
}

Порядок має значення. Якщо поміняти місцями — кеш може не встигнути оновитись до переходу на нову сторінку.

Чек-лист безпеки для продакшну

Перш ніж деплоїти, пройдіться по цьому списку. Серйозно, роздрукуйте і повісьте над монітором:

  1. Валідація кожного поля — Zod або інша бібліотека серверної валідації. Клієнтська — тільки для UX, і ніколи не замінює серверну.
  2. Автентифікація в кожній дії — перевіряйте сесію всередині Server Action, навіть якщо сторінка захищена middleware.
  3. Авторизація — перевіряйте не лише «хто», а й «що може». IDOR (Insecure Direct Object Reference) — одна з найпоширеніших вразливостей, і пробити її простіше, ніж здається.
  4. Не зберігайте секрети в замиканнях — Next.js серіалізує змінні замикань і надсилає їх клієнту (хоч і зашифровано). Виносьте дії в окремі файли з 'use server'.
  5. Rate limiting — обмежуйте частоту запитів до чутливих дій: реєстрація, логін, відновлення паролю, надсилання email.
  6. Не показуйте внутрішні помилки — «Connection refused to postgres:5432» — це подарунок для атакуючого. Завжди повертайте загальне «Щось пішло не так».
  7. Оновіть Next.js — критичні CVE (CVE-2025-55182, CVE-2025-66478) виправлені в нових версіях. Перевірте, що ваш проєкт на актуальному патчі.

FAQ

Чи можна використовувати Server Actions для отримання даних?

Технічно — так. Практично — не варто. Server Actions працюють через POST і призначені для мутацій. Для отримання даних краще підходять Server Components або Route Handlers — вони підтримують кешування та GET-запити.

Чим useActionState відрізняється від useFormState?

useFormState з react-dom — це застарілий хук, перейменований в useActionState і перенесений до react. Новий хук додає вбудований прапорець pending, працює не лише з формами (а з будь-якими асинхронними діями) і краще інтегрується з React Server Components.

Чи потрібна клієнтська валідація, якщо є серверна?

Серверна — обов'язкова для безпеки, тут без варіантів. Клієнтська — опціональна, але дуже покращує UX: миттєвий зворотний зв'язок без мережевого запиту. Найкращий підхід — використовувати ту саму Zod-схему на обох сторонах.

Як працює CSRF-захист у Server Actions?

Next.js закриває це автоматично: дозволений лише POST-метод, SameSite cookies, порівняння заголовків Origin і Host. Плюс кожна Server Action отримує зашифрований, непередбачуваний ID ендпоінта. У більшості випадків додаткових дій не потрібно.

Як обробляти несподівані помилки?

Загортайте бізнес-логіку в try/catch і повертайте загальне повідомлення. Для неперехоплених виключень обгорніть форму в React Error Boundary — це запобіжить «білому екрану». І головне — ніколи не показуйте стек-трейси чи назви таблиць користувачу.

Про Автора Editorial Team

Our team of expert writers and editors.