Server Actions выглядят как вызов функции: импортировали, дёрнули из компонента, получили результат. Именно эта иллюзия и создаёт главный риск темы — за вызовом стоит HTTP-эндпоинт, доступный снаружи, и относиться к нему нужно как к эндпоинту, а не как к функции.
Разберём синтаксис и, отдельно, что обязано быть внутри каждого действия, чтобы оно не стало дырой в приложении.
Что такое Server Actions
Server Actions — это функции, которые выполняются на сервере, но вызываются из клиентских компонентов. Они заменяют API routes для большинства задач: отправка форм, мутации данных, взаимодействие с БД.
До Server Actions:
Клиент → fetch('/api/submit') → API Route → БДС Server Actions:
Клиент → submitForm() → Сервер (та же функция) → БДПреимущества: Меньше кода, типизация из коробки, автоматическая оптимизация, прогрессивное улучшение (работают без JS).
Базовый синтаксис
В серверных компонентах
// app/contact/page.tsx
export default function ContactPage() {
// Server Action прямо в компоненте
async function submitForm(formData: FormData) {
'use server'; // Обязательная директива
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
// Сохраняем в БД
await db.contact.create({
data: { name, email, message }
});
// Ревалидируем страницу
revalidatePath('/contact');
}
return (
<form action={submitForm}>
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Отправить</button>
</form>
);
}В отдельном файле
// app/actions/contact.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
// Схема валидации
const ContactSchema = z.object({
name: z.string().min(2, 'Имя слишком короткое'),
email: z.string().email('Некорректный email'),
message: z.string().min(10, 'Сообщение слишком короткое'),
});
export type ContactFormState = {
errors?: {
name?: string[];
email?: string[];
message?: string[];
};
message?: string;
success?: boolean;
};
export async function submitContactForm(
prevState: ContactFormState,
formData: FormData
): Promise<ContactFormState> {
// Валидация
const validatedFields = ContactSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Проверьте правильность заполнения полей',
};
}
const { name, email, message } = validatedFields.data;
try {
// Сохраняем
await db.contact.create({
data: { name, email, message }
});
// Отправляем уведомление
await sendTelegramNotification(`Новая заявка от ${name}: ${email}`);
revalidatePath('/contact');
return { success: true, message: 'Сообщение отправлено!' };
} catch (error) {
return { message: 'Ошибка сервера. Попробуйте позже.' };
}
}Использование в клиентском компоненте
// app/contact/ContactForm.tsx
'use client';
import { useActionState } from 'react';
import { submitContactForm, type ContactFormState } from '@/app/actions/contact';
const initialState: ContactFormState = {};
export function ContactForm() {
const [state, formAction, pending] = useActionState(
submitContactForm,
initialState
);
return (
<form action={formAction}>
<div>
<input
name="name"
placeholder="Имя"
aria-describedby="name-error"
/>
{state.errors?.name && (
<p id="name-error" className="error">
{state.errors.name[0]}
</p>
)}
</div>
<div>
<input
name="email"
type="email"
placeholder="Email"
aria-describedby="email-error"
/>
{state.errors?.email && (
<p id="email-error" className="error">
{state.errors.email[0]}
</p>
)}
</div>
<div>
<textarea
name="message"
placeholder="Сообщение"
aria-describedby="message-error"
/>
{state.errors?.message && (
<p id="message-error" className="error">
{state.errors.message[0]}
</p>
)}
</div>
<button type="submit" disabled={pending}>
{pending ? 'Отправка...' : 'Отправить'}
</button>
{state.message && (
<p className={state.success ? 'success' : 'error'}>
{state.message}
</p>
)}
</form>
);
}Примеры использования
CRUD операции
// app/actions/posts.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
// CREATE
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const post = await db.post.create({
data: { title, content, authorId: getCurrentUserId() }
});
revalidatePath('/posts');
redirect(`/posts/${post.id}`);
}
// UPDATE
export async function updatePost(id: string, formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.post.update({
where: { id },
data: { title, content }
});
revalidatePath(`/posts/${id}`);
revalidatePath('/posts');
}
// DELETE
export async function deletePost(id: string) {
await db.post.delete({ where: { id } });
revalidatePath('/posts');
redirect('/posts');
}Вызов без формы
// app/posts/[id]/DeleteButton.tsx
'use client';
import { deletePost } from '@/app/actions/posts';
import { useTransition } from 'react';
export function DeleteButton({ postId }: { postId: string }) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (confirm('Удалить пост?')) {
startTransition(async () => {
await deletePost(postId);
});
}
};
return (
<button onClick={handleDelete} disabled={isPending}>
{isPending ? 'Удаление...' : 'Удалить'}
</button>
);
}Оптимистичные обновления
// app/posts/LikeButton.tsx
'use client';
import { useOptimistic, useTransition } from 'react';
import { likePost } from '@/app/actions/posts';
export function LikeButton({
postId,
initialLikes
}: {
postId: string;
initialLikes: number;
}) {
const [isPending, startTransition] = useTransition();
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(state) => state + 1
);
const handleLike = () => {
startTransition(async () => {
addOptimisticLike(null); // Сразу показываем +1
await likePost(postId); // Реальный запрос
});
};
return (
<button onClick={handleLike} disabled={isPending}>
❤️ {optimisticLikes}
</button>
);
}Server Action — это публичный эндпоинт. Next.js создаёт для него адрес и обработчик; вызвать его можно напрямую, минуя вашу форму, ваш интерфейс и любую клиентскую проверку. Отсюда три обязательных вещи внутри каждого действия, изменяющего данные:
Проверка авторизации. Не в компоненте, который его вызывает, а внутри самого действия. Компонент виден только авторизованному пользователю — это ничего не значит для того, кто обращается к эндпоинту напрямую.
Проверка прав на конкретный объект. «Пользователь авторизован» и «пользователь может удалить именно этот заказ» — разные проверки. Идентификатор объекта приходит от клиента, и подменить его тривиально.
Валидация всех аргументов. Типы TypeScript исчезают при компиляции и никак не проверяются в рантайме. Аргументы приходят из сети и должны валидироваться схемой, как тело любого другого запроса.
Ошибка здесь особенно легко допускается именно из-за удобства: код выглядит как обычный вызов функции, а «своим функциям» проверять аргументы не принято.
Обработка ошибок
// app/actions/safe-action.ts
'use server';
import { z } from 'zod';
type ActionResult<T> =
| { success: true; data: T }
| { success: false; error: string };
export async function safeAction<T>(
action: () => Promise<T>
): Promise<ActionResult<T>> {
try {
const data = await action();
return { success: true, data };
} catch (error) {
console.error('Server Action Error:', error);
if (error instanceof z.ZodError) {
return { success: false, error: 'Ошибка валидации' };
}
if (error instanceof Error) {
// В production не показываем детали ошибки
return {
success: false,
error: process.env.NODE_ENV === 'development'
? error.message
: 'Произошла ошибка'
};
}
return { success: false, error: 'Неизвестная ошибка' };
}
}Безопасность: Server Actions доступны как HTTP endpoints. Всегда проверяйте авторизацию и валидируйте входные данные!
Аутентификация
// app/actions/auth.ts
'use server';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
async function getCurrentUser() {
const cookieStore = await cookies();
const sessionId = cookieStore.get('session')?.value;
if (!sessionId) return null;
return await db.user.findUnique({
where: { sessionId }
});
}
// Защищённый action
export async function updateProfile(formData: FormData) {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
const name = formData.get('name') as string;
await db.user.update({
where: { id: user.id },
data: { name }
});
revalidatePath('/profile');
}Когда использовать Server Actions vs API Routes
| Сценарий | Server Actions | API Routes |
|---|---|---|
| Формы | ✅ Идеально | Избыточно |
| CRUD операции | ✅ Идеально | Можно |
| Внешние webhooks | ❌ Нет | ✅ Обязательно |
| Публичный API | ❌ Нет | ✅ Обязательно |
| Загрузка файлов | ✅ Можно | ✅ Можно |
| Real-time (WebSocket) | ❌ Нет | ✅ Нужен route |
Итоги
Server Actions действительно убирают заметную часть рутины: не нужно описывать маршрут, сериализовать запрос, обрабатывать ответ. Форма работает и без JavaScript, типы сквозные, кода меньше.
Что важно помнить.
Действие — публичный эндпоинт. Авторизация, проверка прав на объект и валидация аргументов — внутри каждого действия, изменяющего данные. Это не перестраховка, а базовое требование.
Действия последовательны. Несколько действий, вызванных подряд, выполняются одно за другим, а не параллельно. Для независимых операций это ощутимая задержка, и в таких случаях обычный обработчик маршрута оказывается быстрее.
Для внешних потребителей нужен обычный API. Вебхуки, мобильное приложение, сторонняя интеграция — всё это требует стабильного документированного адреса, которого у действия нет.
Не выносите бизнес-логику в само действие. Пусть оно остаётся тонким слоем: проверить права, разобрать вход, вызвать сервис, вернуть результат. Логика внутри действия не переиспользуется — ни из очереди, ни из консольной команды, ни из обработчика вебхука, — и первый же такой запрос потребует её оттуда выковыривать.
Возвращайте ошибки, а не выбрасывайте их. Необработанное исключение в продакшене превратится в обезличенное сообщение без деталей, и пользователь не узнает, что именно он ввёл неверно. Структурированный результат с полем ошибки даёт нормальный интерфейс.
Частые вопросы
Что такое Server Actions и чем они лучше API routes?
Это функции, выполняющиеся на сервере, но вызываемые из компонентов: не нужно описывать маршрут, сериализовать запрос и обрабатывать ответ. Форма работает даже без JavaScript за счёт прогрессивного улучшения, типы сквозные от компонента до базы, кода заметно меньше. Для форм и CRUD-операций это идеальный инструмент.
Почему Server Action нужно защищать как эндпоинт?
Потому что Next.js создаёт для него адрес и обработчик, и вызвать его можно напрямую, минуя вашу форму и любую клиентскую проверку. Компонент виден только авторизованному пользователю — это ничего не значит для того, кто обращается к эндпоинту напрямую. Ошибка здесь допускается особенно легко именно из-за удобства: код выглядит как обычный вызов функции, а своим функциям проверять аргументы не принято.
Что обязано быть внутри каждого Server Action?
Три вещи, если действие изменяет данные. Проверка авторизации внутри самого действия, а не в вызывающем компоненте. Проверка прав на конкретный объект: «пользователь авторизован» и «пользователь может удалить именно этот заказ» — разные проверки, а идентификатор объекта приходит от клиента. И валидация всех аргументов схемой вроде zod: типы TypeScript исчезают при компиляции и в рантайме ничего не проверяют, а аргументы приходят из сети.
Когда нужны API routes, а не Server Actions?
Для внешних потребителей: вебхуки платёжных систем, мобильное приложение, публичный API, WebSocket. Всё это требует стабильного документированного адреса, которого у действия нет. Формы, CRUD и загрузка файлов закрываются действиями, а real-time и публичный API — только обычными обработчиками маршрутов.
Server Actions выполняются параллельно?
Нет, последовательно: несколько действий, вызванных подряд, выполняются одно за другим. Для независимых операций это ощутимая задержка, и в таких случаях обычный обработчик маршрута оказывается быстрее.
Как обрабатывать ошибки в Server Actions?
Возвращать структурированный результат с полем ошибки, а не выбрасывать исключение. Необработанное исключение в продакшене превратится в обезличенное сообщение без деталей, и пользователь не узнает, что именно он ввёл неверно. При этом текст исключения наружу отдавать не надо: детали логируются на сервере, а пользователю уходит понятное сообщение. Для форм удобен useActionState с типизированным состоянием ошибок по полям. И не выносите бизнес-логику в само действие — пусть оно остаётся тонким слоем, иначе логику не переиспользовать ни из очереди, ни из консольной команды.