← назад к разделу

Обычное React-приложение живёт в браузере: сервер отдаёт почти пустой HTML со ссылкой на скрипт, скрипт скачивается, запускается и рисует страницу. Для админки за паролем это нормально — там некому смотреть на пустую страницу первые секунды, и поисковикам туда не нужно. Для публичного сайта это два ощутимых минуса сразу: пользователь видит пустоту, пока грузится скрипт, а поисковый робот видит страницу без содержимого.

Next.js решает обе задачи тем, что часть работы переносит на сервер. Разберём, что именно там происходит и когда это оправдано.

Что не так с рендером только в браузере

Посмотрим, что приходит из сети у обычного приложения на Vite:

<body>
  <div id="root"></div>
  <script type="module" src="/assets/index-a1b2c3.js"></script>
</body>

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

  1. HTML пришёл — экран пустой.
  2. Скачался скрипт — React смонтировался, показал заглушку загрузки.
  3. Пришли данные — появилось содержимое.

На быстром ноутбуке это доли секунды, на телефоне в метро — несколько секунд пустого экрана. И всё это время в исходном коде страницы нет ни заголовка, ни текста: поисковик может выполнить скрипт, но делает это не всегда и не сразу.

Серверный рендер: HTML приходит готовым

При серверном рендере (server-side rendering, SSR) те же React-компоненты выполняются на сервере, и в браузер приходит готовая разметка:

<body>
  <div id="root">
    <h1>Беспроводная мышь</h1>
    <p class="price">1990 ₽</p>
  </div>
  <script type="module" src="/_next/static/chunks/main-a1b2c3.js"></script>
</body>

Пользователь видит содержимое сразу, поисковик тоже. Дальше скрипт всё-таки скачивается и «оживляет» разметку — навешивает обработчики событий, подключает состояние. Этот шаг называется гидратацией: React проходит по готовому HTML и связывает его со своим деревом компонентов, не перерисовывая заново.

Отсюда правило, о которое спотыкаются все: то, что отрисовал сервер, и то, что React ожидает увидеть на клиенте, должно совпадать. Если совпадения нет, в консоли появится ошибка гидратации. Классический источник — данные, которые на сервере и на клиенте разные:

// Так делать нельзя: на сервере одно время, на клиенте другое
export function Clock() {
  return <span>{new Date().toLocaleTimeString()}</span>;
}

Такие вещи выносят в эффект, который выполняется только в браузере, или откладывают до гидратации.

Серверные компоненты

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

Серверные компоненты (React Server Components, RSC) — попытка это исправить. Компонент выполняется на сервере, в браузер уезжает только его результат, а сам код — нет. В Next.js на роутере приложений (app/) компоненты серверные по умолчанию:

// app/products/[id]/page.tsx — серверный компонент
import { db } from "@/shared/db";

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await db.product.findById(params.id);   // прямо в компоненте

  return (
    <article>
      <h1>{product.title}</h1>
      <p>{product.price} ₽</p>
    </article>
  );
}

Обрати внимание на две вещи. Компонент помечен async и ждёт данные прямо внутри — никакого useEffect и состояния загрузки. И он обращается к базе напрямую: этот код в браузер не попадёт, значит ни строки подключения, ни SQL там не окажется.

Цена — серверный компонент не умеет того, что требует браузера: ни состояния, ни эффектов, ни обработчиков событий. Как только нужен клик, поле ввода или useState, компонент становится клиентским. Это объявляется директивой в первой строке файла:

"use client";

import { useState } from "react";

export function AddToCart({ productId }: { productId: string }) {
  const [count, setCount] = useState(1);

  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>+</button>
      <span>{count}</span>
    </div>
  );
}

Практический приём: держать клиентскими листья дерева, а не ветки. Страница и разметка — серверные, а интерактивный кусок — маленький клиентский компонент внутри. Если поставить "use client" на страницу целиком, всё её поддерево станет клиентским, и смысл затеи потеряется.

Server Actions: форма без ручного запроса

Обычный путь отправки формы такой: собрать данные, вызвать fetch, обработать ответ, обновить состояние, не забыть про ошибки. Для этого приходится завести ручку на бэкенде и клиентский код к ней.

Server Actions позволяют объявить серверную функцию и вызвать её из формы напрямую:

// app/products/new/page.tsx
import { db } from "@/shared/db";
import { revalidatePath } from "next/cache";

async function createProduct(formData: FormData) {
  "use server";

  const title = String(formData.get("title"));
  await db.product.create({ title });
  revalidatePath("/products");
}

export default function NewProductPage() {
  return (
    <form action={createProduct}>
      <input name="title" required />
      <button type="submit">Создать</button>
    </form>
  );
}

Директива "use server" говорит: эта функция выполняется на сервере, а Next сам сделает под неё запрос. Форма работает даже до того, как загрузился скрипт, — потому что это обычная HTML-форма с обработчиком на сервере.

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

async function deleteProduct(id: string) {
  "use server";

  const user = await currentUser();
  if (!user || !user.canManage(id)) {
    throw new Error("Нельзя удалить чужой товар");
  }
  await db.product.delete(id);
}

Когда Next.js нужен, а когда нет

Признак, по которому выбор делается почти механически:

  • Публичный сайт, который должен находиться в поиске и быстро показывать первый экран — Next.js. Витрина товаров, блог, документация, лендинг.
  • Приложение за авторизацией — обычное приложение на Vite. Админка, дашборд, личный кабинет: поисковику туда нельзя, а пустой экран на секунду видит только сотрудник, который и так каждый день сюда заходит.

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

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

Что меняется в структуре проекта

Маршруты в Next задаются файлами: путь в файловой системе и есть адрес страницы.

app/
├── layout.tsx           общая обёртка всех страниц
├── page.tsx             /
├── products/
│   ├── page.tsx         /products
│   └── [id]/
│       └── page.tsx     /products/42
└── api/
    └── health/route.ts  /api/health

Специальные файлы рядом со страницей закрывают состояния, которые в обычном приложении пишут руками: loading.tsx показывается, пока страница ждёт данные, error.tsx — если она упала, not-found.tsx — если данных нет.

При этом сама архитектура кода от Next не зависит: слои и границы остаются теми же, что и в приложении на Vite. Разумный приём — держать app/ тонким: страница только собирает нужные куски, а логика лежит в слоях, как описано в статье про Feature-Sliced Design.

Коротко

  • В обычном React-приложении HTML пустой: содержимое появляется после загрузки скрипта. Для публичного сайта это плохо и для пользователя, и для поиска.
  • Серверный рендер выполняет компоненты на сервере и отдаёт готовую разметку; затем происходит гидратация — скрипт «оживляет» готовый HTML.
  • Разметка на сервере и на клиенте должна совпадать, иначе будет ошибка гидратации: время, случайные значения и данные из браузера — частая причина.
  • Серверные компоненты выполняются только на сервере, их код не уезжает в браузер и может ходить в базу напрямую; состояния и обработчиков в них нет.
  • Клиентскими делают листья дерева, а не страницы целиком: "use client" распространяется на всё поддерево.
  • Server Actions позволяют вызвать серверную функцию из формы без ручного запроса, но это публичная точка входа: права и валидация обязательны.
  • Next нужен публичным сайтам ради поиска и первого экрана; за авторизацией он чаще лишний.

Что почитать дальше

  • Статические сайты: сборка заранее и MDX — когда сервер при каждом запросе не нужен вовсе.
  • Feature-Sliced Design — как разложить код по слоям независимо от фреймворка.
  • Роутинг в React — маршруты в приложении, которое живёт в браузере.