Дії сервера в Next.js
Server Actions - це async-функції з директивою 'use server', які виконуються на сервері і дозволяють змінювати дані прямо з React-компонентів, без окремого API-ендпоінта.
Теорія
TL;DR
- Позначаєш функцію
'use server'- вона запускається на сервері, браузер її не бачить - Передаєш в
<form action={...}>- форма працює навіть без JavaScript на сторінці - Викликаєш з Client Component через
useTransition- отримуєш стан завантаження - Валідація обов'язкова: функція публічно доступна як будь-який HTTP-ендпоінт
- Після запису -
revalidatePathабоrevalidateTag, щоб прибрати застарілі кешовані дані
Швидкий приклад
// actions/user.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function updateUserName(formData: FormData) {
const name = formData.get('name') as string
await db.user.update({
where: { id: currentUserId },
data: { name }
})
revalidatePath('/settings') // кажемо Next.js перемалювати цей маршрут
}Next.js автоматично створює HTTP POST-ендпоінт для цієї функції. При сабміті форми браузер звертається до нього напряму - жодного клієнтського fetch не потрібно.
Чим Server Actions відрізняються від Route Handlers
До Server Actions мутація даних через форму вимагала трьох файлів: Server Component для отримання даних, Client Component для стану і Route Handler для POST-запиту. Server Actions замінюють усі три однією функцією. Але є межа: вони призначені тільки для мутацій з UI. Публічні API, вебхуки й інтеграції зі сторонніми сервісами - це завдання для Route Handlers. URL Server Action генерується автоматично і може змінитись між білдами, тому зовнішні сервіси не можуть покладатися на нього.
Як працює директива
'use server' можна поставити на рівні файлу (тоді кожен експорт стає Server Action) або всередині окремої функції:
// Рівень файлу: всі функції у цьому файлі виконуються на сервері
'use server'
export async function createPost(data: PostData) { ... }
export async function deletePost(id: string) { ... }// Рівень функції: тільки ця функція виконується на сервері
export default async function Page() {
async function handleSubmit(formData: FormData) {
'use server'
// виконується на сервері
}
return <form action={handleSubmit}>...</form>
}Рівень файлу - стандартний підхід у великих проектах. Рівень функції зручний для разових дій всередині Server Component.
Використання з формами
Передаєш action напряму в <form action={...}>. React перехоплює сабміт і викликає функцію. Якщо JavaScript ще не завантажився, браузер відправить форму як звичайний POST - action все одно спрацює. Прогресивне покращення без жодного додаткового коду:
// app/settings/page.tsx
import { updateUserName } from '@/actions/user'
export default async function SettingsPage() {
return (
<form action={updateUserName}>
<input name="name" placeholder="Ім'я на IT Lead" />
<button type="submit">Зберегти</button>
</form>
)
}Використання з useTransition
Форми - один сценарій. Але іноді мутацію потрібно викликати з кнопки, не через сабміт. Загортаєш виклик у startTransition:
'use client'
import { useTransition } from 'react'
import { solveProblem } from '@/actions/problem'
export function SolveButton({
problemId,
userId
}: {
problemId: string
userId: string
}) {
const [isPending, startTransition] = useTransition()
return (
<button
disabled={isPending}
onClick={() => startTransition(() => solveProblem(problemId, userId))}
>
{isPending ? 'Перевірка...' : 'Вирішено'}
</button>
)
}isPending стає true поки action виконується. Використовуй для блокування кнопки або показу спінера. У React 19 з'явився useActionState - він дає зручніший API, коли потрібно ще й читати значення, яке повертає action.
Валідація даних
Server Action доступний як HTTP-ендпоінт. Ставитись до вхідних даних треба так само, як до будь-якого зовнішнього запиту:
'use server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
const schema = z.object({
name: z.string().min(2).max(50),
email: z.string().email()
})
export async function updateUser(formData: FormData) {
const result = schema.safeParse({
name: formData.get('name'),
email: formData.get('email')
})
if (!result.success) {
return { error: result.error.flatten().fieldErrors }
}
await db.user.update({
where: { id: currentUserId },
data: result.data
})
revalidatePath('/settings')
return { success: true }
}Повертай об'єкт з error або success замість того, щоб кидати виняток. Клієнтський компонент зможе прочитати це значення і показати помилки на рівні окремих полів.
Інвалідація кешу після запису
Next.js кешує агресивно. Після зміни даних потрібно позначити, що кеш застарів:
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createProblem(data: ProblemData) {
await db.problem.create({ data })
revalidateTag('problems') // очищує записи кешу з тегом 'problems'
revalidatePath('/problems') // очищує Full Route Cache для цього шляху
redirect('/problems') // перенаправляє після створення
}revalidateTag точніший: він очищує тільки ті fetch-запити, що були позначені { next: { tags: ['problems'] } }. revalidatePath прибирає все, що кешовано для цього URL. Якщо кілька маршрутів використовують одні дані - revalidateTag доречніший.
Типові помилки
1. Відсутність валідації
// Неправильно: довіряємо сирому FormData
export async function updateUser(formData: FormData) {
await db.user.update({ data: { name: formData.get('name') } })
}
// Правильно: спочатку валідуємо
const result = schema.safeParse({ name: formData.get('name') })
if (!result.success) return { error: result.error.flatten() }2. 'use server' у файлі Client Component
Server Actions не можна оголошувати всередині файлу з 'use client'. Оголошуй їх в окремому файлі або в Server Component, а потім імпортуй у Client Component.
3. Забули викликати revalidate
Мутація пройшла, але UI показує старі дані. За моїм досвідом це найпоширеніший баг, який трапляється з командами, що тільки починають використовувати Server Actions. Після кожного запису - revalidatePath або revalidateTag. Якщо використовуєш redirect, Next.js автоматично інвалідує цільовий маршрут.
4. Server Actions замість публічного API
URL Server Action генерується при білді і не є стабільним між деплоями. Зовнішні сервіси не можуть звертатися до нього надійно. Для вебхуків і зовнішніх інтеграцій - Route Handlers.
5. Виняток замість повернення помилки
// Ризиковано: action кидає виняток, компонент потрапляє в Error Boundary
await updateUser(formData)
// Краще: повертаємо помилку об'єктом
const result = await updateUser(formData)
if (!result.success) setErrors(result.error)Де використовуються на практиці
- Форми налаштувань профілю: ім'я, аватар, email
- Позначення виконаних елементів: вирішені задачі, прочитані нотифікації
- Створення записів: пост, коментар, задача
- Auth-флоу: вхід, вихід, реєстрація разом з
redirect - Оптимістичний UI: разом з
useOptimistic(React 19) для миттєвого відгуку до відповіді сервера
Питання на співбесіді
Q: Що станеться, якщо Server Action кине необроблений виняток?
A: Помилка потрапить до найближчого Error Boundary, якщо action викликається з Client Component. При виклику напряму з форми сторінка перемалюється без краша. Повертати об'єкт з помилкою надійніше, ніж кидати виняток.
Q: Чи може Server Action перенаправити користувача?
A: Так. Виклик redirect('/path') з next/navigation працює так само, як у Server Component - всередині він кидає спеціальний виняток, який Next.js перехоплює і обробляє.
Q: Яка різниця між Server Action і Route Handler?
A: Server Actions прив'язані до дерева React-компонентів, приймають FormData і викликаються з UI. Route Handlers - стандартні HTTP-ендпоінти для будь-яких клієнтів: мобільних застосунків, сторонніх сервісів, cron-завдань. Викликати Server Action з мобільного застосунку ненадійно.
Q: Як Next.js захищає Server Actions від CSRF?
A: Next.js генерує випадковий ID дії під час білду і перевіряє його при кожному POST. Це базовий захист від CSRF за замовчуванням. Авторизацію - хто має право викликати конкретну функцію - перевіряти треба самостійно всередині action.
Q: Чи можна викликати Server Action з іншого Server Action?
A: Так, але це звичайний async-виклик без HTTP. Просто імпортуєш і викликаєш. Директива 'use server' створює HTTP-ендпоінт тільки для викликів з клієнта.
Приклади
Форма профілю з валідацією і показом помилок
// actions/profile.ts
'use server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
const profileSchema = z.object({
name: z.string().min(2).max(50),
})
export async function updateProfile(
prevState: unknown,
formData: FormData
) {
const result = profileSchema.safeParse({ name: formData.get('name') })
if (!result.success) {
return { error: result.error.flatten().fieldErrors }
}
await db.user.update({
where: { id: currentUserId },
data: result.data,
})
revalidatePath('/settings')
return { success: true }
}// app/settings/page.tsx
'use client'
import { useActionState } from 'react'
import { updateProfile } from '@/actions/profile'
export default function SettingsPage() {
const [state, action] = useActionState(updateProfile, null)
return (
<form action={action}>
<input name="name" />
{state?.error?.name && <p>{state.error.name[0]}</p>}
<button type="submit">Зберегти</button>
</form>
)
}useActionState пов'язує значення, яке повертає action, зі станом компонента. Параметр prevState отримує попереднє значення при кожному виклику - зручно для накопичення помилок або підрахунку спроб.
Оптимістичне оновлення з useOptimistic
'use client'
import { useOptimistic, useTransition } from 'react'
import { toggleSolved } from '@/actions/problem'
export function ProblemList({ problems }: { problems: Problem[] }) {
const [optimisticProblems, addOptimistic] = useOptimistic(
problems,
(state, solvedId: string) =>
state.map(p => (p.id === solvedId ? { ...p, solved: true } : p))
)
const [, startTransition] = useTransition()
function handleSolve(problemId: string) {
startTransition(async () => {
addOptimistic(problemId) // UI оновлюється миттєво
await toggleSolved(problemId) // зберігаємо в БД
})
}
return (
<ul>
{optimisticProblems.map(p => (
<li key={p.id}>
{p.title}
<button onClick={() => handleSolve(p.id)} disabled={p.solved}>
{p.solved ? 'Виконано' : 'Позначити виконаним'}
</button>
</li>
))}
</ul>
)
}Список оновлюється до того, як сервер відповів. Якщо action завершиться помилкою, React автоматично відкатить оптимістичний стан.
Auth-флоу з redirect
// actions/auth.ts
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { verifyCredentials, createSession } from '@/lib/auth'
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
export async function login(formData: FormData) {
const result = loginSchema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
})
if (!result.success) {
return { error: 'Невірні дані' }
}
const user = await verifyCredentials(result.data)
if (!user) {
return { error: 'Неправильний email або пароль' }
}
const session = await createSession(user.id)
cookies().set('session', session.token, { httpOnly: true, secure: true })
redirect('/dashboard')
}Валідація, перевірка облікових даних, httpOnly-кука і перенаправлення - все в одній функції, без Route Handler.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.