Server Actions: практика
Server Actions позволяют вызывать серверный код прямо из формы или кнопки без отдельного API-роута.
Определение
Файл app/actions.ts:
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const body = formData.get('body') as string;
if (!title || !body) {
return { error: 'Заполните все поля' };
}
const post = await db.posts.create({ title, body });
return { data: post };
}
Директива 'use server' в начале файла делает все экспорты серверными функциями.
Вызов из формы
import { createPost } from '@/app/actions';
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="body" required />
<button type="submit">Создать</button>
</form>
);
}
Форма автоматически сериализует данные и вызовет createPost на сервере.
С состоянием
Через useActionState (бывший useFormState):
'use client';
import { useActionState } from 'react';
import { createPost } from '@/app/actions';
export default function NewPost() {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
<form action={formAction}>
<input name="title" />
<textarea name="body" />
{state?.error && <p className="error">{state.error}</p>}
<button disabled={isPending}>
{isPending ? 'Отправка...' : 'Создать'}
</button>
</form>
);
}
Ревалидация
После мутации нужно обновить кеш:
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
const post = await db.posts.create({ ... });
revalidatePath('/posts');
redirect(`/posts/${post.id}`);
}
revalidatePath — по пути. revalidateTag — по тегу:
import { revalidateTag } from 'next/cache';
revalidateTag('posts');
Оптимистичные обновления
'use client';
import { useOptimistic } from 'react';
export default function Comments({ comments }: { comments: Comment[] }) {
const [optimistic, addOptimistic] = useOptimistic(
comments,
(state, newComment: Comment) => [...state, newComment]
);
async function handleSubmit(formData: FormData) {
const text = formData.get('text') as string;
addOptimistic({ id: Date.now(), text, pending: true });
await addComment(text);
}
return (
<>
<ul>
{optimistic.map((c) => (
<li key={c.id} className={c.pending ? 'pending' : ''}>{c.text}</li>
))}
</ul>
<form action={handleSubmit}>
<input name="text" />
<button>Отправить</button>
</form>
</>
);
}
Валидация с Zod
'use server';
import { z } from 'zod';
const PostSchema = z.object({
title: z.string().min(1, 'Заголовок обязателен'),
body: z.string().min(10, 'Минимум 10 символов'),
});
export async function createPost(formData: FormData) {
const parsed = PostSchema.safeParse({
title: formData.get('title'),
body: formData.get('body'),
});
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors };
}
const post = await db.posts.create(parsed.data);
revalidatePath('/posts');
return { data: post };
}
Безопасность
Server Actions доступны по HTTP-эндпоинту. Всегда проверяй:
- Аутентификацию —
auth()перед действием - Авторизацию — имеет ли пользователь право
- Валидацию — никогда не доверяй входным данным
'use server';
export async function deletePost(id: number) {
const user = await auth();
if (!user) throw new Error('Unauthorized');
const post = await db.posts.find(id);
if (post.authorId !== user.id) throw new Error('Forbidden');
await db.posts.delete(id);
revalidatePath('/posts');
}
Преимущества
- Нет отдельного API-роута
- Типы сохраняются между клиентом и сервером
- Работает с формами без JS
- Проgressively enhanced
Ограничения
- Только в Next.js
- Не для внешних клиентов (мобильные, сторонние API)
- Не заменяет REST/GraphQL для публичного API
Задание
- Создай
app/actions.tsс функцией создания поста - Используй
useActionStateдля состояния - Добавь валидацию через Zod
- Ревалидируй путь после создания
Что дальше
Следующий курс: «API-роуты» — REST-эндпоинты в Next.js.