У традиційній клієнт-серверній веб-розробці виконання мутацій даних вимагало написання значного обсягу інфраструктурного коду. Розробнику доводилося створювати окремий REST/GraphQL ендпоінт, вручну налаштовувати клієнтські обробники onSubmit, конфігурувати HTTP-клієнт, відстежувати стан завантаження через useState, обробляти мережеві помилки та керувати інвалідацією клієнтських кешів.
У Next.js App Router та React 19 реалізовано концепцію Server Actions — асинхронних серверних функцій, що викликаються безпосередньо з клієнтських компонентів або передаються в атрибут action HTML-форм.
Ключові архітектурні переваги Server Actions:
revalidatePath/revalidateTag) та повторний рендеринг серверних компонентів повертаються клієнту в межах одного HTTP-відповіді.В основі Server Actions лежить директива 'use server'. Вона інструктує компілятор Next.js (Turbopack) про те, що ця функція або модуль мають виконуватися виключно на сервері.
Коли Server Action передається в клієнтський компонент, компілятор вилучає тіло функції з клієнтського JavaScript-бандла. Натомість генерується унікальний криптографічний ідентифікатор (Action ID), а на клієнті створюється легковажне проксі-посилання (Reference).
Під час виклику функції браузер прозоро надсилає HTTP POST-запит зі службовим заголовком Next-Action. Сервер виконує бізнес-логіку і повертає серіалізовану відповідь.
Концептуально Server Actions являють собою реалізацію архітектури RPC, безшовно поєднану з деревом компонентів React.
Користувач відправляє форму або викликає дію програмно через transition.
За наявності JS викликається синтетична подія; без JS надсилається стандартний нативний POST-запит форми.
Браузер надсилає POST-запит із тілом FormData та унікальним Action ID у заголовку.
Next.js валідує домен запиту для автоматичного запобігання CSRF-атакам.
Якщо заголовки Origin та Host розходяться, сервер негайно повертає 403 Forbidden.
Сервер розшифровує прив'язані параметри .bind(), перевіряє сесію та виконує SQL-запит.
Виклик revalidatePath скидає кеш і відразу запускає повторний рендеринг зачеплених RSC.
Клієнт отримує результат дії та свіжий RSC Flight Payload у тілі однієї відповіді.
React плавно узгоджує оновлені серверні компоненти в DOM без перезавантаження сторінки.
Server Actions можна оголошувати двома способами:
import { db } from '@/lib/db';
import { posts } from '@/lib/schema';
import { revalidatePath } from 'next/cache';
export default function PostsPage() {
// Асинхронна функція всередині Server Component
async function createPost(formData: FormData): Promise<void> {
'use server'; // Директива на початку тіла функції оголошує її як Server Action
const title = formData.get('title');
if (typeof title !== 'string' || !title.trim()) {
return;
}
await db.insert(posts).values({ title });
// Інвалідація кешу для негайного відображення нового поста
revalidatePath('/posts');
}
return (
<main className="p-6">
<form action={createPost} className="flex gap-2">
<input type="text" name="title" required className="border p-2 rounded" />
<button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded">
Створити пост
</button>
</form>
</main>
);
}Для використання дій усередині Client Components ('use client') вони обов'язково мають бути винесені в окремий модуль із директивою 'use server' на початку файлу:
'use server'; // Застосовується до всіх експортованих функцій модуля
import { db } from '@/lib/db';
import { posts } from '@/lib/schema';
import { eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
export async function deletePostAction(postId: string): Promise<{ success: boolean }> {
await db.delete(posts).where(eq(posts.id, postId));
revalidatePath('/posts');
return { success: true };
}Для керування станом форми, обробки валідаційних помилок та відображення статусу очікування в React 19 використовується хук useActionState.
'use client';
import { useActionState } from 'react';
import { createUserAction, type FormState } from '@/app/actions/users';
const initialState: FormState = {
success: false,
message: '',
errors: undefined,
};
export function UserForm() {
// state — поточний стан, повернутий дією
// formAction — проксі-функція для передачі у <form action={...}>
// isPending — прапорець асинхронного виконання запиту
const [state, formAction, isPending] = useActionState(createUserAction, initialState);
return (
<form action={formAction} className="space-y-4 max-w-md">
<div>
<label htmlFor="email" className="block text-sm font-medium">Email</label>
<input
id="email"
name="email"
type="email"
required
className="w-full border rounded p-2"
/>
{state.errors?.email && (
<p className="text-red-500 text-xs mt-1">{state.errors.email[0]}</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="w-full bg-blue-600 text-white py-2 rounded disabled:opacity-50"
>
{isPending ? 'Збереження...' : 'Зареєструвати'}
</button>
{state.message && (
<p className={`text-sm ${state.success ? 'text-green-600' : 'text-red-600'}`}>
{state.message}
</p>
)}
</form>
);
}Хук useFormStatus із пакета react-dom дозволяє дочірнім компонентам зчитувати контекст статусу батьківської форми (наприклад, для блокування кнопки відправки).
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton({ label = 'Надіслати' }: { label?: string }) {
// Хук зчитує контекст найближчої батьківської форми <form>
const { pending, data, method } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-4 py-2 rounded bg-accent-cyan text-black font-semibold disabled:opacity-50 transition-opacity"
>
{pending ? 'Обробка запиту...' : label}
</button>
);
}Хук useOptimistic дозволяє миттєво відобразити зміни в інтерфейсі до завершення мережевого запиту:
'use client';
import { useOptimistic, startTransition } from 'react';
import { likePostAction } from '@/app/actions/posts';
interface LikeButtonProps {
postId: string;
initialLikes: number;
}
export function LikeButton({ postId, initialLikes }: LikeButtonProps) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(currentLikes: number, increment: number) => currentLikes + increment
);
const handleLike = () => {
startTransition(async () => {
// 1. Миттєво оновлюємо інтерфейс на клієнті
addOptimisticLike(1);
// 2. Надсилаємо запит на сервер
await likePostAction(postId);
});
};
return (
<button
type="button"
onClick={handleLike}
className="border border-border px-3 py-1.5 rounded-lg hover:border-accent-cyan"
>
Подобається: {optimisticLikes}
</button>
);
}Чому виклик функції redirect('/dashboard') всередині блоку try { ... redirect('/dashboard') } catch (err) { ... } у Server Action призводить до помилки виконання?
Форма під час відправки передає в Server Action лише об'єкт FormData. Якщо дії потрібен додатковий контекстний аргумент (наприклад, id запису), використовується метод .bind().
'use client';
import { deleteUserAction } from '@/app/actions/users';
interface UserDTO {
id: string;
name: string;
}
interface UserListProps {
user: UserDTO;
}
export function UserItem({ user }: UserListProps) {
// Прив'язуємо аргумент userId першим параметром Server Action
const deleteWithUserId = deleteUserAction.bind(null, user.id);
return (
<div className="flex items-center justify-between p-3 border rounded">
<span>{user.name}</span>
<form action={deleteWithUserId}>
<button type="submit" className="text-red-500 hover:underline">
Видалити
</button>
</form>
</div>
);
}[!IMPORTANT] Server Actions — це відкриті публічні RPC-ендпоінти. Кожна експортована дія доступна для виклику прямим HTTP POST-запитом із будь-якого інструменту (cURL, Postman). Наявність дії всередині закритих сторінок UI не захищає її від несанкціонованого виклику. Перевірка сесії та прав (RBAC) є суворо обов'язковою в першому рядку кожної Server Action.
'use server';
import { cookies } from 'next/headers';
import { verifySessionToken } from '@/lib/auth';
export async function executeAdminTask(data: FormData): Promise<{ success: boolean; error?: string }> {
// Асинхронне читання cookies відповідно до стандарту Next.js 15+
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) {
return { success: false, error: 'Unauthorized: Сесія відсутня' };
}
const session = await verifySessionToken(token);
if (!session || session.role !== 'admin') {
return { success: false, error: 'Forbidden: Недостатньо прав доступу' };
}
// Бізнес-логіка адміністратора...
return { success: true };
}Серверний компонент завантажує з БД об'єкт користувача зі 150 полями (включаючи passwordHash). Усередині компонента оголошено інлайн Server Action: async function update() { 'use server'; await db.save(user.id); }. У чому полягає загроза безпеці?
Функція redirect() з next/navigation навмисно викидає спеціальний службовий виняток React/Next.js (NEXT_REDIRECT), щоб перервати поточний рендеринг і надіслати клієнту заголовок перенаправлення.
Якщо обгорнути виклик redirect() у загальний блок try/catch, цей виняток буде помилково перехоплено як помилку, і редирект не спрацює.
Рішення: Завжди викликайте redirect() після блоку try/catch.
// ❌ ПОМИЛКА: redirect перехоплено блоком catch
'use server';
import { redirect } from 'next/navigation';
export async function createPostAction(prevState: unknown, formData: FormData) {
try {
await db.insert(posts).values({ title: formData.get('title') });
redirect('/posts'); // Викидає виняток NEXT_REDIRECT!
} catch (err: unknown) {
// Редирект спіймано тут, користувач бачить помилку замість переходу!
return { error: 'Не вдалося створити пост' };
}
}
// ✅ ПРАВИЛЬНО: Викликати redirect ПІСЛЯ блоку try/catch
export async function createPostAction(prevState: unknown, formData: FormData) {
try {
await db.insert(posts).values({ title: formData.get('title') });
} catch (err: unknown) {
return { error: 'Не вдалося створити пост' };
}
redirect('/posts'); // Виконується успішно поза блоком перехоплення
}Server Action, визначена з директивою 'use server', доступна абсолютно будь-якому клієнту, який знає її ID (хеш функції). Навіть якщо кнопка видалення прихована в UI для звичайних користувачів, зловмисник може надіслати прямий HTTP POST-запит із заголовком Next-Action і виконати мутацію.
Рішення: Авторизація та валідація прав доступу (RBAC) мають проводитися всередині кожної дії на сервері перед виконанням бізнес-логіки.
Коли Server Action створюється як інлайн-функція всередині серверного компонента, компілятор Next.js шифрує і передає клієнту всі змінні, захоплені з області видимості батьківського компонента.
Рішення: Виносьте дії в окремі модульні файли actions.ts і передавайте лише потрібні примітиви через .bind().
// ❌ НЕБЕЗПЕЧНО: В action захоплено весь об'єкт heavyUser з приватними даними
export default async function ProfilePage() {
const heavyUser = await db.getUser(); // Містить passwordHash, secretKeys, 100 полів
async function updateStatus() {
'use server';
// Хоча використовується тільки id, Next.js зашифрує і передасть на клієнт ВЕСЬ heavyUser!
await db.updateUserStatus(heavyUser.id, 'online');
}
return <form action={updateStatus}><button>Online</button></form>;
}
// ✅ ПРАВИЛЬНО: Виносьте дії в actions.ts і передавайте тільки скалярні ID
// app/actions.ts:
// export async function updateStatus(userId: string) { 'use server'; ... }