Перейти к содержимому

11. Server Actions

Server Actions — одна из самых крутых фич App Router! Представь: ты пишешь обычную async-функцию с 'use server', а Next.js автоматически создаёт для неё API-эндпоинт, подключает её к форме и обрабатывает всё — без тебя! Как будто React и сервер наконец-то пожали друг другу руки 🤝


Директива 'use server' говорит Next.js: “эта функция выполняется только на сервере”. Можно поставить её вверху файла или прямо внутри функции:

// app/actions.ts — отдельный файл для actions
'use server'; // директива в начале файла = все функции — Server Actions
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function createPost(title: string, content: string) {
const post = await db.post.create({
data: { title, content, authorId: getCurrentUserId() },
});
// Обновляем кэш страницы
revalidatePath('/blog');
revalidatePath(`/blog/${post.slug}`);
return post;
}
export async function deletePost(id: number) {
await db.post.delete({ where: { id } });
revalidatePath('/blog');
}

Или инлайн в компоненте:

// app/blog/new/page.tsx — Server Component
export default function NewPostPage() {
// Инлайн Server Action
async function createPost(formData: FormData) {
'use server'; // директива внутри функции
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.post.create({ data: { title, content } });
revalidatePath('/blog');
redirect('/blog');
}
return (
<form action={createPost}>
<input name="title" placeholder="Заголовок" />
<textarea name="content" placeholder="Содержание" />
<button type="submit">Опубликовать</button>
</form>
);
}

Server Actions прекрасно работают с HTML-формами через атрибут action. Это даже работает без JavaScript — Progressive Enhancement в действии! 🌟

app/actions/todo.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
const TodoSchema = z.object({
title: z.string().min(1, 'Заголовок обязателен').max(100),
description: z.string().max(500).optional(),
priority: z.enum(['low', 'medium', 'high']),
});
// Возвращаем состояние для useFormState
export type FormState = {
success?: boolean;
error?: string;
fieldErrors?: Record<string, string[]>;
};
export async function createTodo(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const raw = {
title: formData.get('title'),
description: formData.get('description'),
priority: formData.get('priority'),
};
const result = TodoSchema.safeParse(raw);
if (!result.success) {
return {
error: 'Ошибка валидации',
fieldErrors: result.error.flatten().fieldErrors,
};
}
try {
await db.todo.create({ data: result.data });
revalidatePath('/todos');
return { success: true };
} catch (error) {
return { error: 'Не удалось создать задачу' };
}
}

useFormState (в React 19: useActionState) позволяет получать ответ от Server Action прямо в компоненте:

app/todos/new/page.tsx
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { createTodo, type FormState } from '@/app/actions/todo';
const initialState: FormState = {};
// Компонент кнопки знает о статусе отправки формы
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className={pending ? 'opacity-50 cursor-not-allowed' : ''}
>
{pending ? '⏳ Создание...' : '✅ Создать задачу'}
</button>
);
}
export default function NewTodoForm() {
const [state, formAction] = useFormState(createTodo, initialState);
return (
<form action={formAction} className="space-y-4">
{state.error && (
<div className="bg-red-100 text-red-700 p-3 rounded">
❌ {state.error}
</div>
)}
{state.success && (
<div className="bg-green-100 text-green-700 p-3 rounded">
Задача создана!
</div>
)}
<div>
<input
name="title"
placeholder="Что нужно сделать?"
className="w-full border rounded p-2"
/>
{state.fieldErrors?.title && (
<p className="text-red-500 text-sm mt-1">
{state.fieldErrors.title[0]}
</p>
)}
</div>
<select name="priority" defaultValue="medium">
<option value="low">🟢 Низкий</option>
<option value="medium">🟡 Средний</option>
<option value="high">🔴 Высокий</option>
</select>
<SubmitButton />
</form>
);
}

useFormStatus — хук, доступный только внутри компонента-потомка формы. Он знает, в каком состоянии находится ближайшая родительская форма:

components/ui/SubmitButton.tsx
'use client';
import { useFormStatus } from 'react-dom';
interface SubmitButtonProps {
children: React.ReactNode;
loadingText?: string;
}
export function SubmitButton({ children, loadingText = 'Загрузка...' }: SubmitButtonProps) {
const { pending, data, method, action } = useFormStatus();
// data — FormData текущей отправки
// method — 'get' или 'post'
// action — ссылка на функцию action
return (
<button
type="submit"
disabled={pending}
aria-busy={pending}
className={`
px-4 py-2 rounded font-medium transition-all
${pending
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700 text-white'
}
`}
>
{pending ? (
<span className="flex items-center gap-2">
<span className="animate-spin"></span>
{loadingText}
</span>
) : children}
</button>
);
}

useOptimistic позволяет немедленно обновить UI, не дожидаясь ответа сервера. Как предсказывать будущее, но для интерфейсов 🔮

app/todos/TodoList.tsx
'use client';
import { useOptimistic, useTransition } from 'react';
import { toggleTodo, deleteTodo } from '@/app/actions/todo';
interface Todo {
id: number;
title: string;
completed: boolean;
}
interface Props {
initialTodos: Todo[];
}
export function TodoList({ initialTodos }: Props) {
const [isPending, startTransition] = useTransition();
// useOptimistic: локальное состояние + функция-редьюсер
const [optimisticTodos, addOptimisticUpdate] = useOptimistic(
initialTodos,
(state: Todo[], action: { type: 'toggle' | 'delete'; id: number }) => {
if (action.type === 'toggle') {
return state.map(todo =>
todo.id === action.id
? { ...todo, completed: !todo.completed }
: todo
);
}
if (action.type === 'delete') {
return state.filter(todo => todo.id !== action.id);
}
return state;
}
);
const handleToggle = (id: number) => {
startTransition(async () => {
// Мгновенно обновляем UI
addOptimisticUpdate({ type: 'toggle', id });
// Отправляем на сервер (может занять время)
await toggleTodo(id);
});
};
const handleDelete = (id: number) => {
startTransition(async () => {
addOptimisticUpdate({ type: 'delete', id });
await deleteTodo(id);
});
};
return (
<ul className="space-y-2">
{optimisticTodos.map(todo => (
<li
key={todo.id}
className={`flex items-center gap-3 p-3 rounded ${
todo.completed ? 'opacity-60' : ''
}`}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
/>
<span className={todo.completed ? 'line-through' : ''}>
{todo.title}
</span>
<button onClick={() => handleDelete(todo.id)}>🗑️</button>
</li>
))}
</ul>
);
}

После мутации нужно обновить данные на странице. Для этого есть два основных метода:

'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
export async function updateProduct(id: number, data: FormData) {
const name = data.get('name') as string;
const price = parseFloat(data.get('price') as string);
await db.product.update({
where: { id },
data: { name, price },
});
// Инвалидация по пути — перерендерит страницу
revalidatePath('/products'); // список
revalidatePath(`/products/${id}`); // деталь
revalidatePath('/admin/products'); // админка
// Инвалидация по тегу — если данные тегированы в fetch
revalidateTag('products');
revalidateTag(`product-${id}`);
// Редирект после мутации (выбрасывает исключение внутри!)
redirect('/products');
}
// В fetch можно пометить данные тегами:
async function getProduct(id: number) {
const data = await fetch(`/api/products/${id}`, {
next: { tags: [`product-${id}`, 'products'] }
});
return data.json();
}

Server Actions выполняются на сервере, но вызываются из клиента — значит, нужна защита! ⚠️

'use server';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
// ❌ Плохо: доверяем данным без проверки
export async function badDeletePost(id: number) {
await db.post.delete({ where: { id } }); // Любой может удалить любой пост!
}
// ✅ Хорошо: проверяем аутентификацию и авторизацию
export async function deletePost(id: number) {
// 1. Проверяем аутентификацию
const session = await auth();
if (!session?.user) {
throw new Error('Не авторизован');
}
// 2. Получаем пост и проверяем владельца
const post = await db.post.findUnique({ where: { id } });
if (!post) {
throw new Error('Пост не найден');
}
// 3. Проверяем авторизацию
if (post.authorId !== session.user.id && session.user.role !== 'admin') {
throw new Error('Нет прав на удаление');
}
await db.post.delete({ where: { id } });
revalidatePath('/blog');
}
// CSRF защита — Next.js добавляет автоматически для форм!
// Но для программных вызовов (fetch) нужно использовать
// заголовок 'Next-Action' который Next.js проверяет сам.
// Валидация ВСЕГДА на сервере (клиентская — только UX)
export async function createUser(formData: FormData) {
const headersList = await headers();
const origin = headersList.get('origin');
// Проверяем origin
if (origin !== process.env.NEXT_PUBLIC_APP_URL) {
throw new Error('Invalid origin');
}
// Валидируем все данные серверной стороной
const schema = z.object({
email: z.string().email(),
name: z.string().min(2).max(50),
});
const result = schema.safeParse({
email: formData.get('email'),
name: formData.get('name'),
});
if (!result.success) {
return { error: result.error.flatten() };
}
// Теперь безопасно работаем с данными
await db.user.create({ data: result.data });
}

Server Actions можно вызывать не только из форм, но и из обычных обработчиков:

app/components/LikeButton.tsx
'use client';
import { likePost } from '@/app/actions/posts';
import { useState, useTransition } from 'react';
export function LikeButton({ postId, initialLikes }: {
postId: number;
initialLikes: number;
}) {
const [likes, setLikes] = useState(initialLikes);
const [liked, setLiked] = useState(false);
const [isPending, startTransition] = useTransition();
const handleLike = () => {
if (liked) return;
// Оптимистичное обновление
setLikes(prev => prev + 1);
setLiked(true);
startTransition(async () => {
try {
const result = await likePost(postId);
// Синхронизируем с реальным значением от сервера
setLikes(result.totalLikes);
} catch (error) {
// Откат при ошибке
setLikes(prev => prev - 1);
setLiked(false);
}
});
};
return (
<button
onClick={handleLike}
disabled={liked || isPending}
className={`flex items-center gap-2 px-4 py-2 rounded-full transition-all ${
liked ? 'bg-red-100 text-red-600' : 'bg-gray-100 hover:bg-red-50'
}`}
>
{liked ? '❤️' : '🤍'} {likes}
</button>
);
}

'use server';
import { redirect, notFound, permanentRedirect } from 'next/navigation';
export async function createAndRedirect(formData: FormData) {
const title = formData.get('title') as string;
if (!title) {
return { error: 'Заголовок обязателен' };
}
const post = await db.post.create({
data: { title, slug: slugify(title) },
});
// redirect выбрасывает исключение NEXT_REDIRECT — не оборачивай в try-catch!
redirect(`/blog/${post.slug}`);
}
export async function getPostOrNotFound(slug: string) {
const post = await db.post.findUnique({ where: { slug } });
if (!post) {
// notFound() выбрасывает исключение NEXT_NOT_FOUND
notFound(); // показывает ближайший not-found.tsx
}
return post;
}
export async function handleOldUrl(oldSlug: string) {
const newSlug = await db.redirects.findFirst({
where: { from: oldSlug },
});
if (newSlug) {
permanentRedirect(`/blog/${newSlug.to}`); // 308 Permanent Redirect
}
}