React Server Components показали публике ещё в конце 2020 года как экспериментальную демонстрацию, а в Next.js 13, вышедшем в октябре 2022 года, новая модель App Router привязала их к фреймворку. С мая 2023 года, когда в версии 13.4 App Router объявили стабильным, споры не утихают. Одни команды пересаживаются и хвалят исчезнувшие загрузочные экраны, другие откатываются к классическим клиентским компонентам и ругают лишний слой ограничений. Обе стороны правы, потому что речь о двух разных задачах: серверные компоненты решают проблему доставки лишнего JavaScript, а платят за это усложнением рассуждений о том, где и когда выполняется код. Ниже разбор обеих сторон без рекламы.

Что на самом деле означает директива use client в новой модели

Первое, что ломается в голове у разработчика, знакомого с классическим React, это смысл директивы use client. Она не делает компонент "чисто браузерным". Компонент с этой меткой по-прежнему рендерится на сервере в HTML при первом запросе, потом гидрируется в браузере и только дальше живёт интерактивной жизнью. Смысл директивы другой: она проводит границу. Всё, что импортировано из файла с use client, становится частью клиентского бандла и получает право на состояние, эффекты и обработчики событий.

В App Router всё, что лежит в папке app, по умолчанию является серверным компонентом. Никакой директивы для этого не нужно. Как только компоненту потребовались useState или onClick, над ним ставится метка, и граница проведена. Права и обязанности расходятся по средам жёстко. Серверному компоненту доступны прямые запросы к базе и чтение секретов из окружения, но запрещены useState, useEffect, обработчики событий и обращение к window. Клиентскому компоненту разрешено всё перечисленное, но не прямой доступ к базе и не секреты. Складывается трёхслойная картина: серверные компоненты без границ, клиентские островки с границей и обычный SSR для них же при первой отрисовке. Разработчик, который этого не прочувствовал, будет часами искать, почему его хук не работает:

useState only works in Client Components. Add the "use client" directive

Такое сообщение об ошибке видит каждый, кто первый раз переносит старый компонент в новую папку.

Какие реальные выгоды серверные компоненты дают приложению

Выгоды описаны во всех презентациях, но каждая из них проверяемая. Первое, доступ к источникам прямо из компонента. Серверный компонент это обычная async-функция, в которой допустим прямой запрос к базе или внутреннему сервису без промежуточного слоя REST-заглушек:

export default async function Page({ params }) {
  const { id } = await params;
  const post = await db.post.findUnique({ where: { id } });

  return (
    <article>
      <h1>{post.title}</h1>
      <LikeButton likes={post.likes} />
    </article>
  );
}

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

Третье, меньше JavaScript в браузере. Тяжёлые библиотеки парсинга, подсветки синтаксиса и валидации остаются на сервере, а бандл получает только интерактивные островки вроде LikeButton:

'use client';

import { useState } from 'react';

export function LikeButton({ likes }) {
  const [liked, setLiked] = useState(false);
  return (
    <button onClick={() => setLiked(!liked)}>
      {likes + (liked ? 1 : 0)}
    </button>
  );
}

Третье с половиной, экономия на тяжёлых зависимостях. Стек для разбора Markdown с подсветкой кода весит в бандле сотни килобайт. В классическом приложении он обязан приехать в браузер целиком. В модели с серверными компонентами тот же стек работает на сервере, пользователь получает готовый HTML, а бандл ничего об этом не знает.

Четвёртое, метрика First Contentful Paint. HTML приходит постранично, оболочка рисуется сразу, а медленные части досылаются потоком с помощью Suspense:

<Suspense fallback={<ChartSkeleton />}>
  <AsyncChart />
</Suspense>

Каркас страницы виден мгновенно, график приезжает, когда готов. Пользователь начинает читать текст, пока значения для графика ещё считаются, и субъективно страница "летит" даже при медленном бэкенде. Сумма выгод выглядит убедительно, и в контентных проектах она ощущается сразу.

Как значения пересекают границу между сервером и клиентом

Граница безразлична к красоте, ей важна сериализуемость. Серверный компонент передаёт клиентскому только то, что можно упаковать в ответ: строки, числа, массивы и плоские объекты, а даты и коллекции React умеет сериализовать сам. Функцию, экземпляр класса или живое соединение с базой передать нельзя, попытка оборачивается ошибкой сериализации с невнятным стеком.

Вторая ловушка известна каждому второму новичку: клиентский компонент не может импортировать серверный, потому что такой импорт утянул бы серверный код в браузер. Композиция делается наоборот, через "дырки" в островке:

// ClientShell.tsx с меткой use client
export function ClientShell({ sidebar, children }) {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen(!open)}>menu</button>
      {open && sidebar}
      <main>{children}</main>
    </div>
  );
}

Серверный родитель передаёт оба блока через пропы и через детей:

<ClientShell sidebar={<NavPanel />}>
  <ArticleBody />
</ClientShell>

Обратное направление работает через серверные экшены. Функция с меткой use server исполняется на сервере, а с клиента на неё можно сослаться как на обычный обработчик:

'use server';

export async function addComment(formData) {
  await db.comment.create({ data: { text: formData.get('text') } });
}

Клиентский компонент отправляет форму без единой строки ручного fetch:

'use client';

export function CommentForm() {
  return (
    <form action={addComment}>
      <input name="text" />
    </form>
  );
}

Граница, которая была стеной для пропов, стала мостом для мутаций. Секреты остаются на сервере, клиент получает готовый результат.

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

Скрытая стоимость серверных компонентов для мысленной модели команды

Теперь о цене, которую не показывают в демо. Классический React предлагает одну среду: компонент рендерится, гидрируется, живёт в браузере, и разработчик держит в голове одну картинку. Модель с границей требует каждый раз отвечать на вопрос "а это где выполняется?". Хук ниже границы работает, выше превращается в ошибку. Контекст с провайдером обязан жить в клиентском компоненте, потому что серверные компоненты одного провайдера в разные моменты времени не увидят.

Симптомы налога узнаваемы. Разработчик ставит console.log в серверный компонент, открывает консоль браузера и не находит строку, потому что лог уехал в терминал сервера. Смена парадигмы это не слоган из презентации, а смена места, куда смотреть.

Постоянные мелочи складываются в усталость. Библиотека, которая дотягивается до window при импорте, внезапно требует клиентской обёртки, а в терминале всплывает ReferenceError про window при сборке. Событийные обработчики, привычно поставленные на серверный компонент, не работают, и компилятор снова объясняет про use client. Потеря состояния при переходах между страницами удивляет тех, кто не читал про кэш маршрутизатора. Ошибка сериализации пропа сбивает с толку, пока не вспомнишь про функцию в значении.

Отдельная статья, командные договорённости. Ревью требует от рецензента держать в голове место выполнения каждого файла. Документация "как у нас принято" разрастается, а новичок, который пришёл с классического React, первую неделю путается не в синтаксисе, а в карте мира. Команды, пережившие переезд, называют это налогом на мысленную модель, и налог реально взимается.

Кэширование в App Router и смена правил между версиями Next.js

Тема, которая испортила крови сильнее всего. В Next.js 14 ответ любого fetch кэшировался по умолчанию, и страницы радовали скоростью, пока не приходила жалоба "в кабинете видны чужие заказы". В Next.js 15, вышедшем в октябре 2024 года вместе с поддержкой React 19, правила развернули: fetch перестал кэшироваться по умолчанию, GET-обработчики маршрутов тоже, а кэш клиентского маршрутизатора сократили с получаса до пяти минут. Правильный по смыслу шаг, но код, написанный в эпоху дефолтов четырнадцатой версии, меняет поведение при обновлении молча.

Управление осталось явным и довольно тонким:

const fresh = await fetch(url);
const cached = await fetch(url, { cache: 'force-cache' });
const hourly = await fetch(url, { next: { revalidate: 3600 } });

Три варианта чтения одного адреса, и выбор между ними это выбор свежести против производительности для каждого конкретного экрана. Когда значения меняются изнутри приложения, кэш сбрасывают точечно, по метке:

import { revalidateTag } from 'next/cache';

export async function POST() {
  await revalidateTag('products');
}

Актуальные версии фреймворка в ветке 16.x продолжают шлифовать кэш, но вывод из истории простой: перед обновлением мажорной версии надо перечитать раздел про кэширование, даже если очень не хочется.

Сценарии в которых серверные компоненты приносят ощутимую пользу

Выгода модели распределена неравномерно, и честнее назвать места, где она концентрируется:

  1. контентные страницы, статьи, каталоги и лендинги, где много текста и мало интерактива, отдают клиенту минимум JavaScript;
  2. экраны с прямым доступом к базе, личные кабинеты и панели, выигрывают от исчезновения слоя загрузочных запросов к собственному бэкенду;
  3. страницы с тяжёлой серверной обработкой, подсветка кода, разбор Markdown, генерация оглавления, не тратят бандл пользователя;
  4. маршруты с секретами, платёжные формы и интеграции, держат ключи за пределами браузера;
  5. поточная отрисовка сложных дашбордов со смешанной скоростью блоков, где каркас рисуется сразу, а медленные графики приезжают позже;
  6. SEO-критичные маршруты, где серверный HTML важен и без компромиссов с гидратацией.

В списках выше нет места для редактора текста с автосохранением, канбана с драг-н-дропом или мессенджера. Такие интерфейсы почти целиком клиентские, серверная обёртка вокруг них даёт мало, а границ придётся проводить много.

Что выбрать команде для нового старта и при модернизации старого кода

Для нового контентного проекта выбор прост: App Router и серверные компоненты, потому что фреймворк сам ставит их по умолчанию и выгоды собираются без усилий. Для нового интерфейсного приложения, редактора или панели мониторинга, честнее признать, что островная модель с минимумом серверной логики даст тот же результат с меньшим числом границ.

Модернизация старого проекта на pages router требует трезвости. Продуктовая страница переводится на серверный компонент за вечер, и это хорошая инвестиция. Сложный экран с десятком клиентских компонентов лучше не трогать до отдельного решения, потому что каждая перенесённая функция потребует обхода через границы, детей и слоты. Полезная проверка перед миграцией любого экрана, посчитать интерактивные элементы. Если на экране меньше пяти кнопок с состоянием, серверная версия почти наверняка окупится. Если экран сплошной живой, как редактор таблиц, граница превратится в решето из обёрток.

Многие команды едят слона по частям: новые маршруты в App Router, старые живут прежней жизнью, и это официально поддерживаемый гибрид, а не костыль.

Сводить всё к да или нет бессмысленно. Серверные компоненты это инструмент для конкретного класса задач: много чтения, мало интерактива, секреты и потоковая отрисовка. Там, где этих условий нет, инструмент продолжает работать, но налог на мысленную модель взимается без скидок. Команда, которая трезво взвесила оба списка, спокойно спит и после переезда, и после решения остаться на классике. Разница между миром и тревогой в этой истории не в технологии, а в понимании границ её применимости.