Урок 11

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

Задание

  1. Создай app/actions.ts с функцией создания поста
  2. Используй useActionState для состояния
  3. Добавь валидацию через Zod
  4. Ревалидируй путь после создания

Что дальше

Следующий курс: «API-роуты» — REST-эндпоинты в Next.js.