Server Actions — одна з найкращих речей у сучасному Next.js. Одна функція, 'use server', жодних API-роутів, жодного fetch-бойлерплейту. Я використовую їх на кожному проєкті.
Але в них є гострі краї. Після шипінгу Server Actions на трьох клієнтських проєктах — ось чотири патерни, які я більше не повторю.
1. Важкі обчислення всередині екшну
Server Actions виконуються на сервері — очевидно. Що менш очевидно — вони працюють у тому ж процесі, що обслуговує сторінки. Server Action, який виконується 3 секунди, блокує воркер на 3 секунди.
На проєкті з генерацією інвойсів Stripe я засунув рендеринг PDF прямо в Server Action. У розробці працювало чудово (один користувач, без конкаренсі). У продакшні троє користувачів, що генерують інвойси одночасно, уповільнили весь застосунок. Завантаження сторінок деградувало, інші Server Actions стали в чергу за PDF-роботою.
Виправлення: Server Actions мають бути тонкими. Вони валідують вхід, пишуть у базу й тригерять фонову роботу. Генерація PDF переїхала у фонове завдання (проста черга на BullMQ). Server Action тепер пише рядок invoice_requested, повертається негайно, а воркер підхоплює.
'use server'
export async function generateInvoice(formData: FormData) {
const data = invoiceSchema.parse(Object.fromEntries(formData))
// Не роби так:
// const pdf = await renderInvoicePDF(data) // 2-4 секунди
// Роби так:
await db.invoiceJobs.create({ data, status: 'pending' })
revalidatePath('/invoices')
}
Правило: якщо займає більше 500мс — не місце в Server Action.
2. Пропуск валідації вхідних даних
«Це серверна сторона, можна не валідувати.» Чув це від інших розробників. І сам так думав — ненадовго.
Server Actions — публічно доступні ендпоінти. Будь-хто може викликати їх з будь-яким payload. Директива 'use server' створює HTTP-ендпоінт під капотом. Якщо ти довіряєш формі даних, бо «форма відправляє тільки валідні дані» — ти довіряєш клієнту, що прямо протилежне тому, що має робити серверний код.
На одному проєкті Server Action приймав поле role з форми й писав його прямо в базу. У формі був дропдаун з «user» і «admin». У розробці — нормально. У продакшні хтось через DevTools відправив role: "superadmin" — роль, яка існувала в базі, але не була в UI.
Виправлення: валідуй кожен вхід Server Action через Zod (або аналог). Не лише типи — бізнес-правила теж.
'use server'
const updateRoleSchema = z.object({
userId: z.string().uuid(),
role: z.enum(['user', 'editor']), // Тільки дозволені ролі
})
export async function updateRole(formData: FormData) {
const { userId, role } = updateRoleSchema.parse(Object.fromEntries(formData))
// Тепер безпечно писати
}
Правило: Server Actions — це API-ендпоінти. Стався до них як до API-ендпоінтів.
3. Використання Server Actions для отримання даних
Цей патерн я бачу в туторіалах і сам використовував одного разу, перш ніж усвідомив вартість.
// Не роби так
'use server'
export async function getUsers() {
return db.users.findMany()
}
// У клієнтському компоненті:
const [users, setUsers] = useState([])
useEffect(() => {
getUsers().then(setUsers)
}, [])
Це працює. Але втрачає сенс Server Components. Ти шипиш клієнтський компонент, що фетчить дані через Server Action, коли міг би мати Server Component, який фетчить дані напряму — без клієнтського JS, без стейту завантаження, з підтримкою стримінгу.
Патерн вище також має неочевидну проблему продуктивності: дані проходять через серіалізацію Server Action (React Flight protocol), що додає оверхед порівняно з прямим викликом бази в Server Component.
Коли Server Actions мають фетчити дані: коли потрібен рефетч після мутації в клієнтському компоненті, і revalidatePath/revalidateTag недостатньо гранулярні. Все.
// Роби так: Server Component фетчить напряму
async function UsersPage() {
const users = await db.users.findMany()
return <UserList users={users} />
}
Правило: Server Components фетчать дані. Server Actions мутують дані. Розділяй їх.
4. Забуваємо ревалідацію
Server Actions мутують дані. Після мутації закешована сторінка досі показує старі дані. Якщо забути ревалідувати — користувачі бачать застарілий стан до хард-рефрешу.
Я зашипив форму коментарів, де Server Action вставляв коментар, але не викликав revalidatePath. Користувач відправив коментар, форма очистилася (успіх!), але список коментарів не оновився. Він відправив знову. І знову. Три дублікати, перш ніж він оновив сторінку.
Виправлення: кожен Server Action, що пише дані, має ревалідувати. Без винятків.
'use server'
export async function addComment(formData: FormData) {
const data = commentSchema.parse(Object.fromEntries(formData))
await db.comments.create({ data })
// Не забудь:
revalidatePath(`/posts/${data.postId}`)
}
Дві стратегії ревалідації:
revalidatePath(path)— ревалідує конкретний роут. Використовуй, коли мутація зачіпає одну сторінку.revalidateTag(tag)— ревалідує всі фетчі з цим тегом. Використовуй, коли мутація зачіпає кілька сторінок (наприклад, зміна профілю користувача, що впливає на хедер на кожній сторінці).
Тепер у мене є лінт-правило, яке позначає Server Actions з викликами db. або prisma. без відповідного revalidatePath або revalidateTag. З моменту додавання воно зловило три пропущені ревалідації.
Патерн, що працює
Після цих уроків кожен Server Action у мене слідує одній структурі:
'use server'
export async function doThing(formData: FormData) {
// 1. Валідація
const data = schema.parse(Object.fromEntries(formData))
// 2. Авторизація
const session = await auth()
if (!session) throw new Error('Unauthorized')
// 3. Мутація (швидко)
await db.things.create({ data })
// 4. Ревалідація
revalidatePath('/things')
}
Чотири кроки, по порядку, кожного разу. Валідація, авторизація, мутація, ревалідація. Якщо мутація повільна — крок 3 стає «постав фонове завдання в чергу» замість виконання роботи інлайн.
Server Actions — чудовий примітив. Їм просто потрібна та сама дисципліна, що й будь-якому іншому API-ендпоінту — бо це саме те, чим вони є.
Будуєш на Next.js App Router і хочеш уникнути продакшн-підводних каменів? Напиши — я шипив Server Actions на кількох клієнтських проєктах і знаю, де гострі краї.