React Server Components в App Router: где они уместны, а где ломают DX
Я сначала тоже ненавидела server components. Год назад мы переезжали с pages router на app router в Next.js, и первые две недели я ходила и бубнила, что всё это сломанная абстракция. Потом разобралась. Сейчас в проде живут оба моих больших приложения на App Router, и я могу спокойно сказать: где RSC реально снимают боль, а где они добавляют столько граблей, что хочется вернуть getServerSideProps.
Эта статья — не «введение в RSC». Их объяснений в сети уже больше, чем разработчиков, которые их щупали. Это про практику: какие сценарии RSC закрывают чисто, а где из-за них DX обваливается так, что ты теряешь день на одно поле формы.
Короткая база, чтобы говорить на одном языке
В App Router каждый файл по умолчанию серверный. Серверный компонент рендерится на сервере, отдаёт HTML и сериализованное дерево, в браузер не уезжает ни строчки его JS. Чтобы сделать компонент клиентским, ставишь "use client" в начало файла, и вместе с ним клиентскими становятся все импортированные из него модули.
Главное правило, которое я повторяю всем джунам: граница «серверный/клиентский» проходит по импортам, а не по визуальной иерархии. Если серверный компонент рендерит клиентский, это нормально. Если клиентский импортирует серверный — нет, это будет клиентский компонент с другим именем. Поэтому в App Router выгодно держать клиентские «листья» как можно мельче и пушить серверную часть наверх.
Где RSC реально хороши
1. Страницы-каталоги и листинги с фильтрами в URL
Самый честный сценарий. Нам прилетел тикет — оптимизировать листинг на 5К карточек товаров с фильтрами по категории, цене и наличию. На pages router это была страница с getServerSideProps, кучей пропсов и клиентским useState для управления фильтрами. На App Router всё это превращается в один серверный компонент, который читает параметры из searchParams.
// app/catalog/page.tsx
type SearchParams = {
category?: string;
min?: string;
max?: string;
};
export default async function CatalogPage({
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
const { category, min, max } = await searchParams;
const products = await getProducts({ category, min, max });
return (
<section>
<FiltersBar />
<ProductGrid products={products} />
</section>
);
}
Тут ничего не уезжает в клиентский бандл, кроме FiltersBar (он клиентский, потому что меняет URL через useRouter). Карточки товаров — серверные, и в бандле от них пусто. На реальном проекте у нас бандл просел с 280 КБ до 95 КБ gzipped. Это не маркетинг, это просто факт того, что 80% UI больше не нужно гонять через JS-парсер браузера.
2. Страницы с тяжёлыми данными из нескольких источников
Когда тебе на одной странице нужны данные из БД, кеша и внешнего API, серверный компонент с async/await читается линейно и не плодит каскад useEffect.
export default async function DashboardPage() {
const [user, stats, news] = await Promise.all([
getCurrentUser(),
getStats(),
fetch("https://api.example.com/news", {
next: { revalidate: 300 },
}).then((r) => r.json()),
]);
return <Dashboard user={user} stats={stats} news={news} />;
}
Это не «революционно». Это просто async-компонент. Но после года жизни с useQuery на каждый чих, такая прямая работа с данными ощущается как глоток воды.
3. Контент-страницы и SEO
Блоги, документация, посадочные. Тут серверный рендер всегда был естественным, RSC просто упаковали его в более удобный формат: контент рендерится на сервере, а интерактивные куски (поиск, тёмная тема, форма подписки) живут в маленьких клиентских компонентах.
4. Авторизованный контент без флешей
Раньше я делала так: страница приходит как заглушка, в useEffect запрашиваем профиль, рендерим. Пользователь видит мигание. На RSC ты в серверном компоненте читаешь cookie через cookies(), дёргаешь сессию, рендеришь сразу нужный вариант. Никаких заглушек.
Где RSC ломают DX
Теперь честная половина. Места, где я плевалась и где плююсь до сих пор.
1. Сложные интерактивные формы
RSC и формы — отдельная история. Идея с Server Actions хорошая на бумаге: пишешь функцию, помечаешь "use server", дёргаешь её прямо из формы. На практике любая форма сложнее логина — это танец из useFormStatus, useActionState, ручной валидации на клиенте, дублирующей серверной, и обработки оптимистичных апдейтов.
"use client";
import { useActionState } from "react";
import { createComment } from "./actions";
type State = { error?: string; ok?: boolean };
export function CommentForm({ postId }: { postId: string }) {
const [state, action, pending] = useActionState<State, FormData>(
async (_prev, formData) => createComment(postId, formData),
{},
);
return (
<form action={action}>
<textarea name="text" required />
<button disabled={pending}>Отправить</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
);
}
Выглядит чисто. Но как только тебе нужно: показать спиннер на конкретном поле, обновить три разных списка после сабмита, откатить изменение при ошибке сети — ты упираешься в то, что Server Actions это плоская абстракция «отправь и получи ответ», а UI-стейт в форме сложнее. Я в итоге для таких случаев откатилась на обычные REST-эндпоинты и TanStack Query на клиенте. Жить стало легче.
2. Графики, дашборды, всё с интерактивом
Любая страница, где половина UI — это графики, дроп-зоны, drag-and-drop, виртуальные списки. RSC тут не помогает: всё это клиентский код. Зато App Router ставит палки в колёса: контекст-провайдеры приходится оборачивать в отдельные клиентские компоненты, серверный layout не может передать ref в клиентский ребёнок, и каждое второе сторонее решение требует "use client" на корне страницы.
В таких случаях я честно ставлю "use client" на всю страницу и забываю про серверные компоненты. Не пытайтесь героически разрезать дашборд на серверные и клиентские куски, если 95% его — это интерактив. Сэкономите два часа реального времени на оптимизацию ради 0.05 секунды на странице, которую открывают раз в неделю.
3. Передача функций и классов через границу
Через границу серверный→клиентский можно передавать только сериализуемые пропсы. Это значит: ни функций (кроме Server Actions), ни классов, ни Date в некоторых случаях, ни Map/Set. Когда у тебя сложный объект с методами — приходится либо превращать его в DTO, либо делать обёртку.
// нельзя
<ClientComponent user={userInstance} />
// можно
<ClientComponent user={userInstance.toJSON()} />
На больших проектах это превращается в отдельный слой DTO, который ты пишешь и поддерживаешь. На small/medium это терпимо. На большом — добавляет ощутимый налог.
4. Отладка
Стек-трейс ошибки в Server Action или в серверном компоненте часто заканчивается в скомпилированном чанке Next.js, и понять, на какой строке твоего кода всё умерло, нужно отдельным навыком. Source maps в dev-режиме помогают, но не всегда. Особенно весело отлаживать гидрацию: компонент серверный, на сервере отрендерилось одно, на клиенте догидрировало другое, в консоли красным «Hydration mismatch». Я держу под рукой памятку, что обычно это Date.now(), Math.random(), или localStorage в render.
5. Сторонние библиотеки
Не все библиотеки готовы к RSC. Особенно те, что используют useLayoutEffect на верхнем уровне, или что-то делают в импорт-сайд-эффектах. Часть решений — обернуть в "use client" файл-обёртку. Часть — ждать апдейта мейнтейнера. У меня в трекере висит ровно одна такая библиотека уже полгода.
Практическое правило, которым я пользуюсь
Перед тем, как начать страницу, я отвечаю на три вопроса:
- Сколько процентов UI на странице — это интерактив (формы, графики, drag-and-drop)?
- Откуда приходят данные: одна точка или несколько?
- Нужен ли SEO?
Если интерактива <30%, данных много, SEO нужен — RSC отлично заходит. Если интерактива >70% — ставлю "use client" на корне страницы и не страдаю. Если посередине — режу по логическим границам: каркас серверный, интерактивные куски клиентские.
Что я бы хотела видеть в App Router в идеале
Чтобы было честно: RSC не дописанная штука. Я бы хотела:
- Нормальную передачу типизированных Server Actions с автокомплитом по полям формы.
- Возможность переиспользовать одну и ту же функцию и на сервере, и на клиенте без танца с
"use server". - Лучшую поддержку
Suspenseв реальных сценариях, а не в туториалах.
Но даже сейчас, со всеми оговорками, на правильных задачах RSC экономит мне недели работы и килобайты бандла. На неправильных — крадёт дни. Главное — научиться отличать одно от другого.
Что запомнить
RSC — не серебряная пуля и не «новый стандарт, к которому все обязаны прийти». Это инструмент с конкретной зоной применимости: страницы с большим объёмом серверных данных, контент, листинги, авторизованные дашборды без жирного интерактива. На сложных формах и интерактивных приложениях — не насилуйте себя. Поставьте "use client" и работайте как раньше, никто не умрёт.
Если переезжаешь с pages router — не пытайся разом перенести весь проект. Я переносила страницами, начиная с самых простых: контент, потом листинги, в конце — формы и дашборды. Это заняло три месяца на проекте средней руки, и каждый шаг был обратимым.