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

URL: https://dmeshcheryakov.ru/blog/nextjs-server-actions/
Раздел: NextJS
Теги: NextJS, React, Server Actions, TypeScript
Опубликовано: 2026-08-28
Обновлено: 2026-09-07
Автор: Дмитрий Мещеряков (https://dmeshcheryakov.ru)

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

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

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

## Что такое Server Actions

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

**До Server Actions:**
```
Клиент → fetch('/api/submit') → API Route → БД
```

**С Server Actions:**
```
Клиент → 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 Actions | API Routes |
|----------|----------------|------------|
| Формы | ✅ Идеально | Избыточно |
| CRUD операции | ✅ Идеально | Можно |
| Внешние webhooks | ❌ Нет | ✅ Обязательно |
| Публичный API | ❌ Нет | ✅ Обязательно |
| Загрузка файлов | ✅ Можно | ✅ Можно |
| Real-time (WebSocket) | ❌ Нет | ✅ Нужен route |

## Итоги

Server Actions действительно убирают заметную часть рутины: не нужно описывать маршрут, сериализовать запрос, обрабатывать ответ. Форма работает и без JavaScript, типы сквозные, кода меньше.

Что важно помнить.

**Действие — публичный эндпоинт.** Авторизация, проверка прав на объект и валидация аргументов — внутри каждого действия, изменяющего данные. Это не перестраховка, а базовое требование.

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

**Для внешних потребителей нужен обычный API.** Вебхуки, мобильное приложение, сторонняя интеграция — всё это требует стабильного документированного адреса, которого у действия нет.

**Не выносите бизнес-логику в само действие.** Пусть оно остаётся тонким слоем: проверить права, разобрать вход, вызвать сервис, вернуть результат. Логика внутри действия не переиспользуется — ни из очереди, ни из консольной команды, ни из обработчика вебхука, — и первый же такой запрос потребует её оттуда выковыривать.

**Возвращайте ошибки, а не выбрасывайте их.** Необработанное исключение в продакшене превратится в обезличенное сообщение без деталей, и пользователь не узнает, что именно он ввёл неверно. Структурированный результат с полем ошибки даёт нормальный интерфейс.