Серверные экшены: RPC поверх POST, который надо считать публичным эндпоинтом
Серверный экшен — RPC поверх POST: 'use server' даёт функции id для вызова клиентом. Формы работают до гидратации; аргументы React-сериализуемы. Каждый экшен — публичный эндпоинт: валидируйте и авторизуйте внутри, ревалидируйте после мутации; внешним клиентам — route handlers.
На стол B2B-команды ложится отчёт пентеста с критической находкой: любой залогиненный пользователь может удалить аккаунт любого другого. Команда в недоумении — кнопка удаления рендерится только админам, они проверяли. Воспроизведение пентестера кнопку не использует. Он один раз открыл network tab под админом, увидел POST, который делает серверный экшен — тот же URL, что у страницы, заголовок Next-Action с непрозрачным id, — и воспроизвёл этот запрос из сессии обычного пользователя. Экшен выполнился. У deleteUser проверка авторизации была в компоненте («рендерить кнопку только админу») и отсутствовала в функции. Но серверный экшен — это не вызов функции, защищённый вашим UI; это HTTP-эндпоинт, который компилятор сгенерировал из вашей функции, доступный любому, кто может отправить POST с нужным id. UI-шлагбаум был бутафорией; эндпоинт был публичным. Этот сдвиг в голове — каждая функция с 'use server' есть неавторизованный API-роут, пока вы не напишете auth внутри неё — и отделяет серверные экшены как выигрыш в продуктивности от серверных экшенов как вашего следующего инцидента.
Механика: во что на самом деле компилируется ‘use server’
Директива 'use server' — в начале async-функции или целого файла — велит бандлеру вырезать функцию из клиентского графа и заменить каждую клиентскую ссылку на неё стабильным id. Вызов экшена из браузера отправляет POST на URL текущей страницы с заголовком Next-Action, несущим этот id, и аргументами в теле; сервер маршрутизирует запрос в скомпилированную функцию, выполняет её и стримит обратно возвращаемое значение вместе с обновлённым RSC-payload. Это RPC со спрятанной обвязкой: ни файла маршрута, ни вызова fetch, ни именования эндпоинта — но провод никуда не делся, и всё, что его пересекает, подчиняется правилам провода.
Аргументы и возвращаемые значения должны сериализоваться протоколом React — это надмножество JSON, понимающее Date, Map, Set, FormData, типизированные массивы и промисы, но не экземпляры классов, не функции и не что-либо с методами. Верните Prisma-модель с полем-классом Decimal — получите runtime-ошибку сериализации; привычка — возвращать плоские DTO (формы вида id плюс name, собранные намеренно). Замыкания работают — экшен, определённый внутри серверного компонента, может захватить переменные из его области видимости, — и Next шифрует захваченные значения ключом конкретной сборки, прежде чем пропустить их через клиент. Но шифрование — не авторизация: никогда не считайте замкнутое значение секретом, который пользователю нельзя видеть, и не доверяйте ему как защищённому от подмены входу.
Ещё одно правило провода, которое удивляет команды: один клиент выполняет экшены последовательно — вызовы становятся в очередь, и медленный экшен блокирует стоящие за ним. Экшены — примитив мутаций, а не универсальный механизм параллельной загрузки данных.
// app/actions.ts
'use server';
import { z } from 'zod';
import { revalidateTag } from 'next/cache';
import { getSession } from '~/lib/auth';
const Input = z.object({ projectId: z.string().uuid(), name: z.string().min(1).max(120) });
export async function renameProject(raw: unknown) {
// 1. Аутентификация + авторизация ВНУТРИ экшена — единственный существующий шлагбаум.
const session = await getSession();
if (!session) throw new Error('Unauthenticated');
// 2. Валидация: провод нетипизирован; TypeScript-сигнатуры — не валидация.
const input = Input.parse(raw);
const project = await db.project.findUnique({ where: { id: input.projectId } });
if (project?.ownerId !== session.userId) throw new Error('Forbidden');
// 3. Мутация, затем уведомление кешей.
await db.project.update({ where: { id: input.projectId }, data: { name: input.name } });
revalidateTag(`project-${input.projectId}`);
return { ok: true as const };
}Прогрессивное улучшение: формы, работающие до JavaScript
Передайте экшен форме — <form action={renameProject}> — и форма работает до гидратации: при выключенном, медленном или ещё не доехавшем JS браузер делает обычный form POST, Next маршрутизирует его в экшен, и страница перерендеривается с результатом. На гидрированной странице та же форма апгрейдится до RPC-пути без полной перезагрузки. useActionState надстраивает состояние (предыдущий результат, флаг pending и улучшенный экшен), а useFormStatus отдаёт дочернему компоненту pending-состояние для блокировки кнопки отправки.
На медленных сетях это реальный, измеримый выигрыш: пользователь на 3G, к которому 200 КБ JS ещё не доехали, всё равно может отправить форму чекаута. Но гарантия узкая — она покрывает только отправку форм. Экшен, повешенный на onClick, выстреливаемый из useEffect или вызываемый императивно, нуждается в гидрированном JavaScript, как любой другой обработчик. Команды, рассказывающие «наше приложение работает без JS, потому что мы на серверных экшенах», при этом дёргая каждую мутацию из кнопок с click-обработчиками, имеют прогрессивное улучшение в буклете, а не в продукте.
Компромисс: путь формы ограничивает входы FormData — по сути строками, — а значит, парсинг и приведение типов на сервере (zod с z.coerce отрабатывает свой хлеб). RPC-путь принимает богатые сериализуемые аргументы, но отдаёт гарантию без-JS. Выбирайте по мутации: чекаут и логин тяготеют к формам, drag-and-drop-сортировка — очевидно нет.
Серверный экшен deleteUser рендерится в форму только на админском дашборде, за проверкой isAdmin в компоненте. Обычный аутентифицированный пользователь вручную отправляет POST с id экшена. Что произойдёт и какова правильная модель?
Безопасность: каждый экшен — публичный эндпоинт
История пентеста обобщается в чек-лист. Аутентифицируйте и авторизуйте внутри каждого экшена — экшен достижим независимо от того, какие компоненты рендерились, так что тело функции — единственный существующий шлагбаум. Валидируйте каждый аргумент — сигнатура TypeScript есть фикция этапа компиляции; в рантайме тело POST — это байты под контролем атакующего, поэтому парсите схемой до прикосновения к базе, ровно как в route handler. Когда пишете экшен, спросите себя: если залогиненный пользователь переиграет этот POST с произвольным projectId — что случится? Этот вопрос обнаруживает брешь в авторизации до пентестера. Считайте экспортированные экшены своей API-поверхностью: Next.js действительно выкидывает id экшенов, на которые нет ссылок, а id недетерминированы между сборками — но «неугадываемый id» это обскурность, а не контроль доступа, потому что всякий, кому UI легитимно служит, видит id в собственном network tab.
Часть ноши фреймворк несёт сам: серверные экшены принимают только POST, и Next сравнивает заголовок Origin с Host, отклоняя расхождения — солидная same-site CSRF-защита из коробки (с конфигом allowedOrigins для прокси-схем, где они легитимно различаются). Точно понимайте, что это покупает: она останавливает межсайтовую подделку и ничего не делает с вашими собственными аутентифицированными пользователями, вызывающими найденные ими экшены, — это задача авторизации, которую решает только ваш код.
Сценарий отказа: более тонкая версия пентест-бага — экшен, авторизующий тип сущности, но не экземпляр: проверяет «залогинен» и дальше обновляет любой пришедший projectId. IDOR (Insecure Direct Object Reference — небезопасная прямая ссылка на объект), массово присваиваемый через типизированную на вид сигнатуру функции. Проверка владения в примере кода (project.ownerId !== session.userId) — та самая строка, которую пропускают на код-ревью, потому что функция «вызывается только со страницы владельца».
Ревалидация после мутации — и когда предпочесть route handlers
Мутация, которая прошла, но оставила протухшие кеши, в баг-репорте неотличима от мутации, которая не прошла («я сохранил, и ничего не изменилось»). Завершайте каждый меняющий состояние экшен объявлением того, что он инвалидировал: revalidatePath или revalidateTag чистит серверные кеши и — поскольку выполнен внутри экшена — инвалидирует клиентский Router Cache мутировавшего пользователя, так что UI перед его глазами обновляется тем же круговым путём. redirect() компонуется с этим для сценария «создал — смотришь». Забытая строка ревалидации — самый частый production-баг серверных экшенов, и в разработке он невидим, потому что кеширование там почти выключено.
Серверные экшены — для мутаций вашего собственного приложения из вашего собственного React-дерева. Предпочитайте route handler, когда вызывающий — не ваш UI: вебхуки от Stripe или CMS, мобильные клиенты, сторонние интеграции — они не могут адресовать непрозрачный, меняющийся от сборки к сборке id экшена и нуждаются в стабильном URL с явными методами. Route handlers выигрывают и для GET-эндпоинтов (экшены — POST-мутации по дизайну), для ответов, не сериализуемых React (файлы, кастомные заголовки, редиректы с конкретными статус-кодами), и для высококонкурентной клиентской стрельбы — экшены одного клиента выполняются последовательно, а запросы к route handler идут параллельно. Разумное командное правило: экшены — для внутриприложенческих мутаций из форм и кнопок, route handlers — для всего, у чего есть внешний вызывающий или строчка в OpenAPI.
CMS должна уведомлять сайт об изменении статьи, а вашему мобильному приложению (нативному, без React) нужна та же возможность публикации статьи, что и у серверного экшена в вебе. Куда направить этих двух вызывающих?
- 01Опишите проводную механику серверного экшена: как он вызывается, что может пересечь провод и каково ограничение исполнения.
- 02Почему каждый экшен обязан валидировать и авторизовать внутри, что покрывает CSRF-проверка фреймворка и когда выбирать route handler?
Серверный экшен — это удалённый вызов процедуры со спрятанной обвязкой: ‘use server’ вырезает функцию из клиентского бандла, даёт ей стабильный пер-сборочный id, и вызов становится POST-ом на URL страницы с заголовком Next-Action; сервер выполняет функцию и стримит обратно результат с обновлённым RSC-payload. Действуют правила провода: аргументы и возвраты должны быть React-сериализуемы (Date, Map, Set, FormData — да; экземпляры классов и функции — нет, так что возвращайте намеренно собранные плоские DTO), замыкания шифруются ключом сборки, но это не та секретность и не та целостность, на которые стоит опираться, а один клиент выполняет экшены последовательно, что делает их примитивом мутаций, а не параллельной загрузкой. Формы с экшеном работают до гидратации — обычный POST, апгрейдящийся до RPC с приходом JS, с useActionState и useFormStatus для pending и результата, — но гарантия без-JS покрывает только отправку форм, а путь FormData означает серверный парсинг и приведение типов. Несущий урок — модель безопасности: каждый экшен есть публичный эндпоинт, достижимый любым, кто способен прислать id, независимо от того, какие компоненты рендерились, поэтому аутентификация, по-экземплярная авторизация (проверки владения, а не только «залогинен» — классический пропуск IDOR) и схемная валидация обязаны жить внутри функции; сравнение Origin/Host фреймворка блокирует межсайтовую подделку и ничего больше. Завершайте каждую мутацию ревалидацией — revalidatePath/revalidateTag чистят серверные кеши и изнутри экшена обновляют Router Cache мутировавшего пользователя; забытая ревалидация — главный production-баг, потому что dev-режим его прячет. И знайте границу инструмента: внешних вызывающих — вебхуки, нативные мобильные приложения, всё, чему нужны стабильный URL, GET-семантика, несериализуемые ответы или параллелизм, — обслуживают route handlers, потому что непрозрачный пер-сборочный id экшена — это не API-контракт. Теперь, когда пишешь экшен, проверяешь три вещи перед релизом: есть ли внутри проверка владения, разобран ли каждый аргумент схемой и завершается ли каждая мутация вызовом revalidate.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.