Server Actions в Next.js — замена API routes

Server Actions позволяют вызывать серверные функции прямо из компонентов без создания API-эндпоинтов. Разбираем синтаксис, валидацию, обработку ошибок и лучшие практики.

Дмитрий Мещеряков
Дмитрий Мещеряков
📅 28 августа 2026 г.📖 7 мин чтения

Server Actions выглядят как вызов функции: импортировали, дёрнули из компонента, получили результат. Именно эта иллюзия и создаёт главный риск темы — за вызовом стоит HTTP-эндпоинт, доступный снаружи, и относиться к нему нужно как к эндпоинту, а не как к функции.

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

Что такое Server Actions

Server Actions — это функции, которые выполняются на сервере, но вызываются из клиентских компонентов. Они заменяют API routes для большинства задач: отправка форм, мутации данных, взаимодействие с БД.

До Server Actions:

text
Клиент → fetch('/api/submit') → API Route → БД

С Server Actions:

text
Клиент → submitForm() → Сервер (та же функция) → БД
💡 Совет

Преимущества: Меньше кода, типизация из коробки, автоматическая оптимизация, прогрессивное улучшение (работают без JS).

Базовый синтаксис

В серверных компонентах

typescript
// 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>
  );
}

В отдельном файле

typescript
// 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: 'Ошибка сервера. Попробуйте позже.' };
  }
}

Использование в клиентском компоненте

typescript
// 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 операции

typescript
// 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');
}

Вызов без формы

typescript
// 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>
  );
}

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

typescript
// 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 исчезают при компиляции и никак не проверяются в рантайме. Аргументы приходят из сети и должны валидироваться схемой, как тело любого другого запроса.

Ошибка здесь особенно легко допускается именно из-за удобства: код выглядит как обычный вызов функции, а «своим функциям» проверять аргументы не принято.

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

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. Всегда проверяйте авторизацию и валидируйте входные данные!

Аутентификация

typescript
// 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 ActionsAPI 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 с типизированным состоянием ошибок по полям. И не выносите бизнес-логику в само действие — пусть оно остаётся тонким слоем, иначе логику не переиспользовать ни из очереди, ни из консольной команды.