БлогУслугиКарьера
Обсудить проект
БлогУслугиКарьераОбсудить проект
React / Next.js

React Server Actions: практический гайд для продакшена

Всё о Server Actions в React и Next.js: формы, мутации данных, валидация, обработка ошибок. Паттерны для реальных проектов.

Редакция Feature
Редакция Feature·
24 мар
·
13 мин
·
React Server Actions: практический гайд для продакшена

Что такое Server Actions и зачем они нужны

Server Actions — это функции, которые выполняются на сервере, но вызываются напрямую из клиентского кода. Они появились в React как способ упростить мутации данных, заменив классическую связку «API-эндпоинт + fetch-запрос с клиента» одной серверной функцией.

До Server Actions типичный флоу для отправки формы выглядел так:

  1. Пользователь заполняет форму
  2. Клиентский код собирает данные и делает fetch('/api/submit', { method: 'POST', body: ... })
  3. API-роут на сервере принимает запрос, валидирует, сохраняет в базу
  4. Клиент обрабатывает ответ, обновляет UI

С Server Actions это становится проще:

  1. Пользователь заполняет форму
  2. Форма вызывает серверную функцию напрямую
  3. Функция валидирует, сохраняет, ревалидирует кэш
  4. UI обновляется автоматически

Никаких API-эндпоинтов, никакого ручного управления запросами. Это особенно удобно в экосистеме Next.js 15, где Server Actions полностью интегрированы в App Router. Подробнее о возможностях фреймворка читайте в нашей статье Next.js 16: что нового и как использовать в production.

Базовое использование с формами

Определение Server Action

Server Action — это async-функция с директивой 'use server'. Она может быть определена двумя способами:

Способ 1: В отдельном файле (рекомендуется)

// app/actions/posts.ts
"use server";

import {revalidatePath} from "next/cache";
import {db} from "@/lib/database";

export async function createPost(formData: FormData) {
    const title = formData.get("title") as string;
    const content = formData.get("content") as string;

    await db.post.create({
        data: {title, content},
    });

    revalidatePath("/posts");
}

Способ 2: Inline в серверном компоненте

// app/posts/new/page.tsx — Server Component
export default function NewPostPage() {
    async function createPost(formData: FormData) {
        "use server";

        const title = formData.get("title") as string;
        // ...сохранение в базу
    }

    return (
        <form action={createPost}>
            <input name="title" required/>
            <button type="submit">Создать</button>
        </form>
    );
}

Первый способ предпочтительнее для production-проектов: действия переиспользуются, код организован, проще тестировать.

Вызов из формы

Самый простой способ вызвать Server Action — передать его в атрибут action формы:

// app/contact/page.tsx — Server Component
import {submitContact} from "@/app/actions/contact";

export default function ContactPage() {
    return (
        <main>
            <h1>Свяжитесь с нами</h1>
            <form action={submitContact}>
                <label>
                    Имя
                    <input name="name" type="text" required/>
                </label>
                <label>
                    Email
                    <input name="email" type="email" required/>
                </label>
                <label>
                    Сообщение
                    <textarea name="message" required/>
                </label>
                <button type="submit">Отправить</button>
            </form>
        </main>
    );
}

Это работает даже без JavaScript на клиенте — форма отправляется как обычный HTML-form submit. Прогрессивное улучшение из коробки.

Хук useActionState

Зачем нужен

В реальных приложениях недостаточно просто отправить форму. Нужно показать состояние загрузки, обработать ошибки, вывести сообщение об успехе. Для этого React 19 предоставляет хук useActionState.

Как использовать

// app/actions/contact.ts
"use server";

type ContactState = {
    success?: boolean;
    error?: string;
    errors?: Record<string, string>;
};

export async function submitContact(
    prevState: ContactState | null,
    formData: FormData
): Promise<ContactState> {
    const name = formData.get("name") as string;
    const email = formData.get("email") as string;
    const message = formData.get("message") as string;

    // Валидация
    const errors: Record<string, string> = {};
    if (!name || name.length < 2)
        errors.name = "Имя должно быть не менее 2 символов";
    if (!email || !email.includes("@")) errors.email = "Введите корректный email";
    if (!message || message.length < 10)
        errors.message = "Сообщение должно быть не менее 10 символов";

    if (Object.keys(errors).length > 0) {
        return {errors};
    }

    // Сохранение
    await db.contact.create({data: {name, email, message}});

    // Отправка уведомления
    await sendNotification({name, email, message});

    return {success: true};
}
// app/contact/contact-form.tsx
"use client";

import {useActionState} from "react";
import {submitContact} from "@/app/actions/contact";

export function ContactForm() {
    const [state, formAction, isPending] = useActionState(submitContact, null);

    if (state?.success) {
        return (
            <div className="p-4 bg-green-100 rounded">
                Спасибо! Мы свяжемся с вами в ближайшее время.
            </div>
        );
    }

    return (
        <form action={formAction} className="space-y-4">
            <div>
                <label htmlFor="name">Имя</label>
                <input
                    id="name"
                    name="name"
                    type="text"
                    className={state?.errors?.name ? "border-red-500" : ""}
                />
                {state?.errors?.name && (
                    <p className="text-red-500 text-sm mt-1">{state.errors.name}</p>
                )}
            </div>

            <div>
                <label htmlFor="email">Email</label>
                <input
                    id="email"
                    name="email"
                    type="email"
                    className={state?.errors?.email ? "border-red-500" : ""}
                />
                {state?.errors?.email && (
                    <p className="text-red-500 text-sm mt-1">{state.errors.email}</p>
                )}
            </div>

            <div>
                <label htmlFor="message">Сообщение</label>
                <textarea
                    id="message"
                    name="message"
                    className={state?.errors?.message ? "border-red-500" : ""}
                />
                {state?.errors?.message && (
                    <p className="text-red-500 text-sm mt-1">{state.errors.message}</p>
                )}
            </div>

            <button type="submit" disabled={isPending}>
                {isPending ? "Отправка..." : "Отправить"}
            </button>
        </form>
    );
}

Обратите внимание: useActionState возвращает три значения — текущее состояние, обёрнутый action для формы и флаг isPending. Это всё, что нужно для полноценной работы с формой.

Валидация с Zod

Почему Zod

В production-проекте нельзя доверять данным из формы. Server Action получает FormData, и каждое поле нужно валидировать. Zod — библиотека для runtime-валидации с TypeScript-типизацией, которая идеально подходит для этой задачи.

Реализация

// lib/validations/post.ts
import {z} from "zod";

export const createPostSchema = z.object({
    title: z
        .string()
        .min(3, "Заголовок должен быть не менее 3 символов")
        .max(200, "Заголовок не должен превышать 200 символов"),
    content: z
        .string()
        .min(50, "Контент должен быть не менее 50 символов")
        .max(50000, "Контент не должен превышать 50 000 символов"),
    category: z.enum(["tech", "business", "design"], {
        errorMap: () => ({message: "Выберите категорию"}),
    }),
    published: z.coerce.boolean().default(false),
});

export type CreatePostInput = z.infer<typeof createPostSchema>;
// app/actions/posts.ts
"use server";

import {revalidatePath} from "next/cache";
import {createPostSchema} from "@/lib/validations/post";
import {db} from "@/lib/database";

type ActionState = {
    success?: boolean;
    error?: string;
    fieldErrors?: Record<string, string[]>;
};

export async function createPost(
    prevState: ActionState | null,
    formData: FormData
): Promise<ActionState> {
    // Парсим FormData в объект
    const rawData = {
        title: formData.get("title"),
        content: formData.get("content"),
        category: formData.get("category"),
        published: formData.get("published"),
    };

    // Валидируем через Zod
    const result = createPostSchema.safeParse(rawData);

    if (!result.success) {
        return {
            fieldErrors: result.error.flatten().fieldErrors as Record<
                string,
                string[]
            >,
        };
    }

    // Данные типизированы и валидированы
    const {title, content, category, published} = result.data;

    try {
        await db.post.create({
            data: {title, content, category, published},
        });
    } catch (error) {
        return {error: "Не удалось создать пост. Попробуйте позже."};
    }

    revalidatePath("/posts");
    return {success: true};
}

Zod парсит данные, проверяет типы и ограничения, а в случае ошибки возвращает структурированные сообщения для каждого поля. Вы получаете и валидацию, и типизацию из одной схемы.

Нужна современная веб-разработка?

Разработаем проект на React/Next.js с Server Actions

Заказать React-разработку

Обработка ошибок

Паттерн: разделение ошибок валидации и системных ошибок

В production важно различать два типа ошибок: ошибки валидации (пользователь ввёл неправильные данные) и системные ошибки (база данных недоступна, внешний сервис не отвечает).

// app/actions/orders.ts
"use server";

import {redirect} from "next/navigation";

type OrderState = {
    fieldErrors?: Record<string, string[]>;
    error?: string;
};

export async function createOrder(
    prevState: OrderState | null,
    formData: FormData
): Promise<OrderState> {
    // 1. Валидация — возвращаем ошибки полей
    const validation = orderSchema.safeParse(Object.fromEntries(formData));
    if (!validation.success) {
        return {fieldErrors: validation.error.flatten().fieldErrors};
    }

    // 2. Бизнес-логика — может бросить ожидаемые ошибки
    const product = await db.product.findUnique({
        where: {id: validation.data.productId},
    });

    if (!product) {
        return {error: "Товар не найден"};
    }

    if (product.stock < validation.data.quantity) {
        return {
            error: `Недостаточно товара на складе. Доступно: ${product.stock}`,
        };
    }

    // 3. Создание заказа — может бросить непредвиденную ошибку
    try {
        const order = await db.order.create({
            data: {
                productId: product.id,
                quantity: validation.data.quantity,
                total: product.price * validation.data.quantity,
            },
        });

        // Успех — редирект на страницу заказа
        redirect(`/orders/${order.id}`);
    } catch (error) {
        // Логируем для отладки, но не показываем детали пользователю
        console.error("Order creation failed:", error);
        return {error: "Произошла ошибка при создании заказа. Попробуйте позже."};
    }
}

Глобальная обработка через error.tsx

Если Server Action выбрасывает необработанное исключение (throw), Next.js перехватывает его через ближайший error.tsx:

// app/orders/error.tsx
"use client";

export default function OrderError({
                                       error,
                                       reset,
                                   }: {
    error: Error & { digest?: string };
    reset: () => void;
}) {
    return (
        <div className="p-8 text-center">
            <h2 className="text-xl font-bold text-red-600">Что-то пошло не так</h2>
            <p className="mt-2 text-gray-600">{error.message}</p>
            <button
                onClick={reset}
                className="mt-4 px-4 py-2 bg-blue-600 text-white rounded"
            >
                Попробовать снова
            </button>
        </div>
    );
}

Оптимистичные обновления

Зачем нужны

Оптимистичные обновления показывают результат действия мгновенно, не дожидаясь ответа сервера. Это создаёт ощущение моментальной работы интерфейса.

Реализация с useOptimistic

"use client";

import {useOptimistic} from "react";
import {toggleLike} from "@/app/actions/likes";

type Comment = {
    id: string;
    text: string;
    likes: number;
    isLiked: boolean;
};

export function CommentList({comments}: { comments: Comment[] }) {
    const [optimisticComments, setOptimisticComment] = useOptimistic(
        comments,
        (state, updatedComment: { id: string; isLiked: boolean }) => {
            return state.map((comment) =>
                comment.id === updatedComment.id
                    ? {
                        ...comment,
                        isLiked: updatedComment.isLiked,
                        likes: updatedComment.isLiked
                            ? comment.likes + 1
                            : comment.likes - 1,
                    }
                    : comment
            );
        }
    );

    async function handleLike(commentId: string, currentlyLiked: boolean) {
        // Мгновенное обновление UI
        setOptimisticComment({id: commentId, isLiked: !currentlyLiked});

        // Фактический запрос на сервер
        await toggleLike(commentId);
    }

    return (
        <ul className="space-y-4">
            {optimisticComments.map((comment) => (
                <li key={comment.id} className="p-4 border rounded">
                    <p>{comment.text}</p>
                    <button
                        onClick={() => handleLike(comment.id, comment.isLiked)}
                        className={comment.isLiked ? "text-red-500" : "text-gray-400"}
                    >
                        {comment.isLiked ? "Liked" : "Like"} ({comment.likes})
                    </button>
                </li>
            ))}
        </ul>
    );
}

Пользователь нажимает «Like» — UI обновляется мгновенно. Если серверный запрос провалится, React автоматически откатит UI к реальному состоянию.

Загрузка файлов

Простая загрузка

Server Actions поддерживают загрузку файлов через FormData:

// app/actions/upload.ts
"use server";

import {writeFile, mkdir} from "fs/promises";
import path from "path";

export async function uploadFile(formData: FormData) {
    const file = formData.get("file") as File;

    if (!file || file.size === 0) {
        return {error: "Файл не выбран"};
    }

    // Валидация типа и размера
    const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
    if (!allowedTypes.includes(file.type)) {
        return {error: "Допустимые форматы: JPEG, PNG, WebP"};
    }

    const maxSize = 5 * 1024 * 1024; // 5 MB
    if (file.size > maxSize) {
        return {error: "Максимальный размер файла: 5 MB"};
    }

    // Сохранение
    const bytes = await file.arrayBuffer();
    const buffer = Buffer.from(bytes);

    const uploadsDir = path.join(process.cwd(), "public", "uploads");
    await mkdir(uploadsDir, {recursive: true});

    const filename = `${Date.now()}-${file.name}`;
    const filepath = path.join(uploadsDir, filename);
    await writeFile(filepath, buffer);

    return {success: true, url: `/uploads/${filename}`};
}
// app/upload/upload-form.tsx
"use client";

import {useActionState} from "react";
import {uploadFile} from "@/app/actions/upload";

export function UploadForm() {
    const [state, formAction, isPending] = useActionState(uploadFile, null);

    return (
        <form action={formAction}>
            <input type="file" name="file" accept="image/jpeg,image/png,image/webp"/>
            {state?.error && <p className="text-red-500">{state.error}</p>}
            {state?.success && (
                <p className="text-green-600">
                    Файл загружен: <a href={state.url}>{state.url}</a>
                </p>
            )}
            <button type="submit" disabled={isPending}>
                {isPending ? "Загрузка..." : "Загрузить"}
            </button>
        </form>
    );
}

Для больших файлов

Для загрузки больших файлов (видео, архивы) Server Actions не подходят — они имеют ограничение на размер body (по умолчанию 1 MB в Next.js). Используйте presigned URL с загрузкой напрямую в S3 или аналогичное хранилище.

Обсудим ваш проект?

Оставьте контакты — перезвоним и обсудим задачу

Безопасность

CSRF-защита

Server Actions в Next.js автоматически защищены от CSRF-атак. Каждый action получает уникальный зашифрованный идентификатор, который проверяется при вызове. Дополнительная защита не требуется.

Авторизация

Каждый Server Action — это публичный HTTP-эндпоинт. Всегда проверяйте авторизацию:

// app/actions/admin.ts
"use server";

import {auth} from "@/lib/auth";
import {redirect} from "next/navigation";

export async function deleteUser(userId: string) {
    const session = await auth();

    // Проверка аутентификации
    if (!session?.user) {
        redirect("/login");
    }

    // Проверка авторизации
    if (session.user.role !== "admin") {
        throw new Error("Forbidden");
    }

    // Только после проверок — действие
    await db.user.delete({where: {id: userId}});
    revalidatePath("/admin/users");
}

Rate Limiting

Для защиты от злоупотреблений добавьте rate limiting:

// lib/rate-limit.ts
const rateLimit = new Map<string, { count: number; resetAt: number }>();

export function checkRateLimit(key: string, limit: number, windowMs: number) {
    const now = Date.now();
    const entry = rateLimit.get(key);

    if (!entry || now > entry.resetAt) {
        rateLimit.set(key, {count: 1, resetAt: now + windowMs});
        return true;
    }

    if (entry.count >= limit) {
        return false;
    }

    entry.count++;
    return true;
}
// app/actions/contact.ts
"use server";

import {headers} from "next/headers";
import {checkRateLimit} from "@/lib/rate-limit";

export async function submitContact(prevState: any, formData: FormData) {
    const headersList = await headers();
    const ip = headersList.get("x-forwarded-for") || "unknown";

    if (!checkRateLimit(`contact:${ip}`, 5, 60 * 1000)) {
        return {error: "Слишком много запросов. Попробуйте через минуту."};
    }

    // ...остальная логика
}

Санитизация входных данных

Никогда не передавайте пользовательский ввод напрямую в SQL-запросы или HTML. Используйте ORM (Prisma, Drizzle) для запросов и санитизацию для вывода:

// ПЛОХО: SQL-инъекция
const result = await db.$queryRaw`SELECT * FROM users WHERE name = ${name}`;

// ХОРОШО: параметризованный запрос через ORM
const result = await db.user.findMany({
    where: {name: name},
});

Тестирование Server Actions

Unit-тестирование

Server Actions — это обычные async-функции. Тестируйте их как любую другую функцию:

// __tests__/actions/posts.test.ts
import {createPost} from "@/app/actions/posts";

// Мокаем зависимости
jest.mock("@/lib/database", () => ({
    db: {
        post: {
            create: jest.fn().mockResolvedValue({id: "1", title: "Test"}),
        },
    },
}));

jest.mock("next/cache", () => ({
    revalidatePath: jest.fn(),
}));

describe("createPost", () => {
    it("создаёт пост с валидными данными", async () => {
        const formData = new FormData();
        formData.set("title", "Тестовый пост");
        formData.set(
            "content",
            "Контент тестового поста, достаточной длины для валидации"
        );
        formData.set("category", "tech");

        const result = await createPost(null, formData);

        expect(result.success).toBe(true);
    });

    it("возвращает ошибки валидации для пустого заголовка", async () => {
        const formData = new FormData();
        formData.set("title", "");
        formData.set("content", "Контент");
        formData.set("category", "tech");

        const result = await createPost(null, formData);

        expect(result.fieldErrors?.title).toBeDefined();
    });
});

E2E-тестирование

Для проверки полного цикла «форма -> Server Action -> обновление UI» используйте Playwright:

// e2e/contact-form.spec.ts
import {test, expect} from "@playwright/test";

test("отправка контактной формы", async ({page}) => {
    await page.goto("/contact");

    await page.fill('[name="name"]', "Иван Иванов");
    await page.fill('[name="email"]', "ivan@example.com");
    await page.fill('[name="message"]', "Тестовое сообщение для проверки формы");

    await page.click('button[type="submit"]');

    // Ждём сообщение об успехе
    await expect(page.getByText("Спасибо!")).toBeVisible();
});

test("показывает ошибки валидации", async ({page}) => {
    await page.goto("/contact");

    // Отправляем пустую форму
    await page.click('button[type="submit"]');

    // Ждём ошибки
    await expect(
        page.getByText("Имя должно быть не менее 2 символов")
    ).toBeVisible();
});

Лучшие практики

Подведём итог в виде конкретных рекомендаций для production-проектов:

Организация кода. Храните все Server Actions в директории app/actions/ или lib/actions/. Группируйте по сущностям: posts.ts, users.ts, orders.ts. Это упрощает навигацию и тестирование.

Типизация. Всегда типизируйте входные и выходные данные. Используйте Zod-схемы для валидации и вывода типов. Определите общий тип ActionState для единообразия.

Ревалидация. После мутации данных всегда вызывайте revalidatePath() или revalidateTag(), чтобы закэшированные данные обновились. Без этого пользователь увидит устаревшую информацию.

Редиректы. Используйте redirect() из next/navigation для перенаправления после успешных действий. Помните, что redirect() выбрасывает исключение — вызывайте его вне try/catch.

Прогрессивное улучшение. Формы с Server Actions работают без JavaScript. Это значит, что базовая функциональность доступна даже при медленном соединении, когда JS ещё не загрузился.

Заключение

Server Actions — это мощный инструмент, который кардинально упрощает работу с мутациями данных в React-приложениях. Они устраняют необходимость в промежуточных API-эндпоинтах, обеспечивают типобезопасность от формы до базы данных и работают без JavaScript на клиенте.

Ключевые моменты для запоминания:

  • Определяйте actions в отдельных файлах с 'use server'
  • Используйте useActionState для управления состоянием формы
  • Валидируйте данные через Zod-схемы
  • Разделяйте ошибки валидации и системные ошибки
  • Всегда проверяйте авторизацию в каждом action
  • Добавляйте rate limiting для публичных форм

Для глубокого понимания архитектуры серверных компонентов рекомендуем прочитать нашу статью Server Components vs Client Components, а для оптимизации производительности — гайд по PageSpeed-оптимизации Next.js.

Если вашему проекту нужна профессиональная React/Next.js разработка с использованием Server Actions и других современных паттернов, наша команда готова помочь. Мы также предоставляем услуги по SEO-продвижению для проектов на Next.js.

Обсудим ваш проект?

Оставьте контакты — перезвоним и обсудим задачу

Содержание
  • Что такое Server Actions и зачем они нужны
  • Базовое использование с формами
  • Хук useActionState
  • Валидация с Zod
  • Обработка ошибок
  • Оптимистичные обновления
  • Загрузка файлов
  • Безопасность
  • Тестирование Server Actions
  • Лучшие практики
  • Заключение
Поделиться:

Похожие статьи

Server Components vs Client Components: когда что использовать
React / Next.js

Server Components vs Client Components: когда что использовать

12 мин
Оптимизация Next.js: от 50 до 100 баллов PageSpeed за 1 день
React / Next.js

Оптимизация Next.js: от 50 до 100 баллов PageSpeed за 1 день

14 мин
Next.js 16: что нового и как использовать в production
React / Next.js

Next.js 16: что нового и как использовать в production

14 мин
Feature IT

Feature IT — платформа по обучению программированию и разработке цифровых продуктов. Мы создаём современные веб-решения для бизнеса и обучаем этому других!

Политика конфиденциальностиПользовательское соглашение

О компании

  • Блог
  • Карьера

Услуги разработки

  • Разработка сайтов под ключ
  • Веб-приложения на React/Next.js
  • Telegram-боты для бизнеса
  • Mini Apps (Telegram, VK)
  • SEO-оптимизированные сайты
  • Автоматизация бизнес-процессов
  • Поддержка и развитие IT-продуктов

Обучение

  • Курс Python с нуля
  • Алгоритмы и структуры данных
  • Паттерны проектирования
  • Подготовка к собеседованиям в IT
  • Практика на реальных проектах

Инструменты

  • Генератор UTM-меток
  • Счётчик символов