Как делать11 октября 2026 г.10 мин чтения
Next.jsШаг 3 из 7

Next.js: серверные компоненты, загрузка данных и Server Actions

Коротко

В App Router компоненты по умолчанию серверные: они могут читать базу и секреты напрямую, а в браузер уходит только результат. Клиентским компонент становится директивой "use client" — она нужна для состояния, обработчиков событий и браузерных API. В Next.js 16 с Cache Components данные по умолчанию не кэшируются: их либо кэшируют через "use cache" с cacheLife и cacheTag, либо оборачивают компонент в <Suspense> и догружают потоком. Изменения делают через Server Actions — функции с "use server", которые после записи вызывают updateTag и сами проверяют ввод и права пользователя.

Пошаговый урокУровень: СреднийNext.jsПрактика: ~3–6 ч

После материала вы сможете

  • решить, какой компонент оставить серверным, а какой сделать клиентским, и провести между ними границу через "use client"
  • загрузить данные в серверном компоненте и выбрать стратегию: кэш "use cache" с cacheTag или стриминг через <Suspense>
  • обработать форму через Server Action с серверной валидацией, useActionState и обновлением кэша через updateTag
  • закрыть модуль доступа к данным через server-only и перечислить проверки, без которых Server Action небезопасен

Проверено на

Next.js 16.4 (Cache Components)React 19.3TypeScript 5.9Node.js 24 LTS (24.18)

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Серверные и клиентские компоненты: где выполняется код
  2. 02"use client" и граница
  3. 03Загрузка данных в серверном компоненте
  4. 04Кэширование: use cache, cacheLife и cacheTag
  5. 05Стриминг и Suspense
  6. 06Server Actions: формы и мутации
  7. 07Безопасность: что никогда не уходит на клиент
  8. 08Упражнения и следующий шаг

Серверные и клиентские компоненты: где выполняется код

В уроке об App Router страницы и макеты просто возвращали разметку. На самом деле все они были серверными компонентами (Server Components): React выполняет их на сервере, а в браузер отправляет готовый результат. Клиентские компоненты (Client Components) рендерятся в HTML на сервере для первой загрузки, но их JavaScript тоже уходит в браузер, где компонент «оживает» — React подключает обработчики событий. Этот процесс называется гидратацией (hydration).

Серверный компонент — когда нужно

  • Читать базу данных или API рядом с источником
  • Использовать ключи API, токены и другие секреты
  • Уменьшить объём JavaScript в браузере
  • Быстрее показать первый контент (FCP) и догрузить остальное потоком

Клиентский компонент — когда нужно

  • Состояние: useState, useReducer
  • Обработчики событий: onClick, onChange
  • Эффекты: useEffect
  • Браузерные API: localStorage, window, геолокация

На сервере Next.js превращает дерево серверных компонентов в RSC Payload (React Server Component Payload) — компактное описание результата: готовая разметка серверных компонентов, «дырки» под клиентские компоненты со ссылками на их JavaScript и пропсы, которые сервер передаёт клиенту. При первой загрузке браузер получает HTML (быстрый неинтерактивный предпросмотр), RSC Payload и JavaScript для гидратации. При следующих переходах HTML уже не нужен — достаточно RSC Payload.

Серверные компоненты читают данныеRSC Payload + HTMLБраузер показывает HTMLГидратация клиентских компонентовСтраница интерактивна
сервербраузер
Первая загрузка: данные и секреты остаются на сервере, в браузер уходят разметка и код только интерактивных частей.

Практика урока — страница заявок CRM студии: список заявок загружается на сервере, счётчик «новых за сегодня» догружается потоком, а форма добавления работает через Server Action. Проект создан create-next-app с настройками по умолчанию, поэтому в next.config.ts включены cacheComponents и partialPrefetching. Вместо базы данных — JSON-файл data/leads.json: так пример запускается без внешних сервисов. Имена и телефоны в нём — выдуманные заглушки.

"use client" и граница

Директива "use client" в начале файла объявляет границу между серверным и клиентским графом модулей. Всё, что такой файл импортирует, и компоненты, которые он рендерит напрямую, попадают в клиентский бандл, поэтому ставить директиву в каждый файл не нужно — только в точку входа интерактивной части. Отсюда главное правило: делайте клиентскими маленькие интерактивные «листья», а не макет или страницу целиком.

  • Серверный компонент может импортировать и рендерить клиентский. Обратный импорт не сделает компонент серверным: всё, что импортирует файл с "use client", становится клиентским кодом.
  • Серверный компонент можно передать клиентскому как children или другой проп: он отрендерится на сервере, а клиент получит готовый результат.
  • Пропсы от сервера к клиенту должны сериализоваться: строки, числа, обычные объекты и массивы, Date, промисы. Функции передавать нельзя — кроме Server Actions.
  • Контекст React в серверных компонентах не работает. Провайдер контекста — клиентский компонент, который принимает children; его ставят как можно глубже в дереве.

В нашем проекте граница проходит по форме: у неё есть состояние ошибок и индикатор отправки, поэтому lead-form.tsx начинается с "use client". Страница, список и счётчик остаются серверными, и их код в браузер не попадает. Саму форму разберём в разделе о Server Actions.

Загрузка данных в серверном компоненте

Серверный компонент может быть асинхронной функцией и ждать данные через await: fetch к API, запрос через ORM, чтение файла. Ключи и запросы к базе при этом не попадают в клиентский бандл. Документация рекомендует собирать доступ к данным в отдельный слой (Data Access Layer): только он читает process.env и базу, проверяет права и решает, какие поля отдать в рендер.

lib/leads.ts
import "server-only";
import { cacheLife, cacheTag } from "next/cache";
import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";

export type Lead = {
  id: string;
  name: string;
  phone: string;
  createdAt: string;
};

// A JSON file stands in for a database in this lesson
const FILE = path.join(process.cwd(), "data", "leads.json");

export async function readLeads(): Promise<Lead[]> {
  const json = await readFile(FILE, "utf8");
  return JSON.parse(json) as Lead[];
}

export async function getLeads(): Promise<Lead[]> {
  "use cache";
  cacheTag("leads");
  cacheLife("hours");
  return readLeads();
}

export async function insertLead(input: { name: string; phone: string }) {
  const leads = await readLeads();
  const lead: Lead = {
    id: randomUUID(),
    ...input,
    createdAt: new Date().toISOString(),
  };
  await writeFile(FILE, JSON.stringify([lead, ...leads], null, 2));
  return lead;
}
Слой доступа к данным: чтение, кэшированное чтение и запись. Файл JSON заменяет базу

Функции getLeads с "use cache" разберём в следующем разделе. Сначала попробуем самый прямой путь — прочитать заявки в странице:

app/leads/page.tsx
import { readLeads } from "@/lib/leads";

export default async function LeadsPage() {
  const leads = await readLeads();

  return (
    <main>
      <h1>Leads</h1>
      <ul>
        {leads.map((lead) => (
          <li key={lead.id}>
            {lead.name} — {lead.phone}
          </li>
        ))}
      </ul>
    </main>
  );
}
Первая попытка: чтение данных прямо в странице, без кэша и Suspense
Terminal
$ npm run build
…
Error: Route "/leads": Next.js encountered uncached or runtime data during prerendering.

`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.

Ways to fix this:
  - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
  - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
  - [block] Set `export const instant = false` to allow a blocking route

Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic
…
⨯ Next.js build worker exited with code: 1 and signal: null
Реальный вывод next build в Next.js 16.4 с Cache Components; путь к проекту и стек сокращены

Это не баг, а принцип Cache Components. Next.js старается собрать для каждого маршрута статическую оболочку (static shell) — HTML, который можно отдать мгновенно, например с CDN. Чтение файла — асинхронный ввод-вывод без кэша: на этапе сборки Next.js не знает, можно ли зафиксировать результат, и требует решения от разработчика. Сообщение перечисляет три варианта:

[cache] — "use cache"

Что происходит
Результат сохраняется и попадает в статическую оболочку
Когда выбирать
Данные одинаковы для всех и могут быть немного устаревшими

[stream] — <Suspense>

Что происходит
В оболочке — заглушка, данные читаются при каждом запросе и догружаются потоком
Когда выбирать
Нужны свежие данные или данные конкретного пользователя

[block] — instant = false

Что происходит
Маршрут рендерится целиком по запросу, пользователь ждёт
Когда выбирать
Временная мера при миграции, не для новых страниц

Кэширование: use cache, cacheLife и cacheTag

Директива "use cache" в начале асинхронной функции или компонента кэширует её результат. Аргументы функции становятся частью ключа кэша: getLead("1") и getLead("2") получат разные записи. В getLeads из lib/leads.ts директива стоит вместе с двумя вызовами — их документация рекомендует добавлять всегда:

  1. cacheLife("hours")

    Срок жизни записи. Профиль hours: клиент считает данные свежими 5 минут (stale), сервер обновляет запись в фоне через час (revalidate), а через сутки без запросов она истекает (expire). Без cacheLife действует профиль default.

  2. cacheTag("leads")

    Метка, по которой запись можно сбросить по требованию — например, сразу после добавления заявки.

  3. readLeads()

    Само чтение. Внутри кэшируемой функции нельзя вызывать cookies(), headers() и читать searchParams — их значения передают аргументами снаружи.

default

stale
5 мин
revalidate
15 мин
expire
никогда

minutes

stale
5 мин
revalidate
1 мин
expire
1 ч

hours

stale
5 мин
revalidate
1 ч
expire
1 день

days

stale
5 мин
revalidate
1 день
expire
1 неделя

max

stale
5 мин
revalidate
30 дней
expire
1 год
Встроенные профили cacheLife (выборка из документации)
app/leads/lead-list.tsx
import { getLeads } from "@/lib/leads";

export async function LeadList() {
  const leads = await getLeads();

  return (
    <ul>
      {leads.map((lead) => (
        <li key={lead.id}>
          {lead.name} — {lead.phone}
        </li>
      ))}
    </ul>
  );
}
Серверный компонент списка: просто вызывает кэшированную функцию
Terminal
$ npm run build
…
Route (app)      Revalidate  Expire
┌ ○ /
├ ○ /_not-found
└ ◐ /leads               1h      1d

○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
Реальный вывод сборки: колонки Revalidate и Expire взяты из профиля hours

Проверить, что попало в статическую оболочку, можно прямо в результатах сборки — Next.js хранит пререндеренный HTML в .next/server/app/:

Terminal
$ grep -o "Aigerim\|Daniyar\|Counting today’s leads…\|New today" .next/server/app/leads.html
Counting today’s leads…
Aigerim
Daniyar
Реальный вывод: в оболочке есть кэшированный список и заглушка счётчика, но нет самого счётчика

Стриминг и Suspense

Счётчик новых заявок должен быть точным на момент запроса, поэтому кэшировать его нельзя. connection() из next/server явно говорит Next.js: дальше код зависит от входящего запроса — после этого вызова можно брать текущее время и читать свежие данные. Компонент помещаем в <Suspense>: заглушка входит в статическую оболочку, а настоящий счётчик сервер дошлёт в том же ответе, как только он будет готов. Это и есть стриминг (streaming) — страница приходит частями.

app/leads/lead-stats.tsx
import { connection } from "next/server";
import { readLeads } from "@/lib/leads";

export async function LeadStats() {
  await connection();
  const leads = await readLeads();
  const today = new Date().toISOString().slice(0, 10);
  const count = leads.filter((lead) => lead.createdAt.startsWith(today)).length;

  return (
    <p>
      New today: {count} (checked at {new Date().toISOString().slice(11, 19)} UTC)
    </p>
  );
}
Некэшируемая часть: читает данные при каждом запросе
app/leads/page.tsx
import { Suspense } from "react";
import { LeadForm } from "./lead-form";
import { LeadList } from "./lead-list";
import { LeadStats } from "./lead-stats";

export default function LeadsPage() {
  return (
    <main>
      <h1>Leads</h1>
      <LeadForm />
      <Suspense fallback={<p>Counting today’s leads…</p>}>
        <LeadStats />
      </Suspense>
      <LeadList />
    </main>
  );
}
Итоговая страница: форма и список — в оболочке, счётчик — потоком

В заголовках ответа на /leads это видно: x-nextjs-postponed: 1 означает, что часть страницы была отложена и пришла потоком после оболочки. При этом Cache-Control: private, no-cache, no-store — ответ целиком не кэшируется браузером и CDN, потому что содержит данные конкретного запроса.

Что оборачивает

loading.tsx
Всю страницу сегмента
<Suspense>
Конкретный компонент

Что видно сразу

loading.tsx
Макет и заглушка вместо страницы
<Suspense>
Вся страница, кроме содержимого границы

Когда удобен

loading.tsx
Страница целиком зависит от медленных данных
<Suspense>
Медленная только одна часть страницы

Чем глубже в дереве стоит асинхронная работа, тем больше страницы попадает в оболочку. Поэтому документация советует не ждать params, cookies() и данные на уровне макета, а передавать промис вниз и ждать его в том компоненте, которому значение действительно нужно. Ещё одна деталь: два последовательных await в одном компоненте выполняются по очереди. Независимые запросы запускайте одновременно и ждите через Promise.all.

Server Actions: формы и мутации

Server Function — асинхронная функция, которая выполняется на сервере, а вызывается из браузера сетевым запросом. Когда её используют для отправки формы или изменения данных, её называют Server Action. Функции помечают директивой "use server" — в начале функции или, как у нас, в начале файла: тогда серверными становятся все экспорты. Отдельный API-маршрут для формы не нужен: Next.js сам отправит POST на текущий адрес и вернёт новые данные и обновлённый интерфейс за один запрос.

app/leads/actions.ts
"use server";

import { updateTag } from "next/cache";
import { insertLead } from "@/lib/leads";

export type LeadFormState = {
  ok: boolean;
  message: string;
  errors?: { name?: string; phone?: string };
  fields?: { name: string; phone: string };
};

export async function createLead(
  _prevState: LeadFormState,
  formData: FormData,
): Promise<LeadFormState> {
  const name = String(formData.get("name") ?? "").trim();
  const phone = String(formData.get("phone") ?? "").trim();

  const errors: NonNullable<LeadFormState["errors"]> = {};
  if (name.length < 2 || name.length > 80) {
    errors.name = "Enter a name from 2 to 80 characters.";
  }
  if (!/^\+?[0-9 ()-]{7,20}$/.test(phone)) {
    errors.phone = "Enter a phone number, for example +7 700 000 00 00.";
  }
  if (errors.name || errors.phone) {
    return { ok: false, message: "Check the form.", errors, fields: { name, phone } };
  }

  await insertLead({ name, phone });
  updateTag("leads");
  return { ok: true, message: `Lead from ${name} saved.` };
}
Server Action: валидация, запись, сброс кэша по тегу
  1. Читаем и нормализуем ввод

    formData.get возвращает FormDataEntryValue | null. Приводим к строке и обрезаем пробелы — клиенту доверять нельзя, даже если в форме есть required.

  2. Ожидаемые ошибки — значением

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

  3. Пишем и сбрасываем кэш

    updateTag("leads") немедленно делает запись с этим тегом устаревшей: следующий рендер прочитает свежие данные, и пользователь сразу увидит свою заявку. Вызывать updateTag можно только внутри Server Action.

app/leads/lead-form.tsx
"use client";

import { useActionState } from "react";
import { createLead, type LeadFormState } from "./actions";

const initialState: LeadFormState = { ok: false, message: "" };

export function LeadForm() {
  const [state, formAction, pending] = useActionState(createLead, initialState);

  return (
    <form action={formAction} noValidate>
      <label>
        Name
        <input
          name="name"
          defaultValue={state.fields?.name}
          aria-invalid={Boolean(state.errors?.name)}
          aria-describedby="name-error"
        />
      </label>
      <p id="name-error">{state.errors?.name}</p>

      <label>
        Phone
        <input
          name="phone"
          type="tel"
          defaultValue={state.fields?.phone}
          aria-invalid={Boolean(state.errors?.phone)}
          aria-describedby="phone-error"
        />
      </label>
      <p id="phone-error">{state.errors?.phone}</p>

      <button type="submit" disabled={pending}>
        {pending ? "Saving…" : "Add lead"}
      </button>
      <p aria-live="polite">{state.message}</p>
    </form>
  );
}
Клиентская форма: состояние ответа, ошибки у полей, индикатор отправки

useActionState из React принимает действие и начальное состояние и возвращает три значения: текущее состояние (последний ответ действия), функцию для action формы и флаг pending. После выполнения действия React сбрасывает неконтролируемые поля формы. При ошибке валидации введённое не теряется: состояние возвращает значения, и они подставляются в defaultValue. Атрибут noValidate в примере отключает браузерную проверку, чтобы было видно серверную; в реальной форме оставьте и required, и type="tel" — это удобнее пользователю.

updateTag(tag)

Где вызывать
Только Server Actions
Поведение
Сразу сбрасывает запись: пользователь видит своё изменение (read-your-own-writes)

revalidateTag(tag, "max")

Где вызывать
Server Actions и Route Handlers
Поведение
Отдаёт устаревшее, пока в фоне готовится свежее (stale-while-revalidate)

revalidatePath(path)

Где вызывать
Server Actions и Route Handlers
Поведение
Сбрасывает всё для маршрута; документация советует предпочитать теги

refresh()

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

Проверка в headless-браузере после next build и next start: отправка с именем «A» и телефоном «abc» показала обе ошибки, поля сохранили введённое, у поля имени появился aria-invalid="true". Корректная заявка появилась первой в списке без перезагрузки страницы, форма очистилась, счётчик «New today» увеличился. Оба раза браузер отправил POST на /leads с заголовком Next-Action. Затем мы закомментировали updateTag и пересобрали проект: заявка записалась в файл, но список остался прежним — кэш с профилем hours честно отдавал старые данные.

Безопасность: что никогда не уходит на клиент

Граница сервер/клиент — это граница безопасности, и ошибки здесь стоят дорого. Первая линия защиты — import "server-only" в модулях, которые работают с базой и секретами. Если такой модуль случайно импортировать в клиентский компонент, сборка упадёт, а не отправит код в браузер. Пакет устанавливать не обязательно: Next.js обрабатывает этот импорт сам. Мы проверили это, импортировав readLeads в клиентский компонент:

Terminal
$ npm run build
…
Error: 'server-only' cannot be imported from a Client Component module
> 1 | import "server-only";
    | ^^^^^^^^^^^^^^^^^^^^^

It should only be used from a Server Component.

Import traces:
  Client Component Browser:
    ./lib/leads.ts [Client Component Browser]
    ./app/leads/bad-client.tsx [Client Component Browser]
    ./app/leads/bad-client.tsx [Server Component]
    ./app/leads/page.tsx [Server Component]
Реальный вывод: сборка остановлена, трассировка показывает, откуда пришёл импорт
  • Секреты — только в переменных окружения без префикса NEXT_PUBLIC_: переменные с этим префиксом Next.js встраивает в клиентский бандл
  • Модули с базой и process.env помечены import "server-only" и собраны в слой доступа к данным
  • В клиентские компоненты передаются только нужные поля, а не целые записи из базы
  • Server Action — публичная точка входа: её можно вызвать прямым POST-запросом, минуя интерфейс. Внутри каждого действия проверяются сессия и права на конкретный объект, даже если страница уже защищена
  • Ввод из formData, параметров адреса и заголовков валидируется на сервере: длина, формат, допустимые значения
  • Действие возвращает только то, что нужно интерфейсу: без стека ошибки и внутренних идентификаторов
  • Для публичных форм (заявки, подписки) предусмотрено ограничение частоты запросов (rate limiting)

Часть защиты Next.js даёт сам. Идентификаторы действий зашифрованы, непредсказуемы и пересчитываются между сборками. Действия вызываются только методом POST, а заголовок Origin сверяется с Host — это закрывает большинство CSRF-атак (подделки межсайтовых запросов). Переменные, которые действие захватывает из замыкания, шифруются. Документация прямо предупреждает: на шифрование как на единственную защиту секретов полагаться не стоит.

Упражнения и следующий шаг

Короткая шпаргалка решений: данные одинаковы для всех и могут подождать — "use cache" с cacheLife и cacheTag; данные свежие или личные — <Suspense> и чтение при запросе; изменение данных — Server Action, валидация, проверка прав, затем updateTag.

Дальше по траектории — Tailwind CSS: оформление этих же страниц утилитами и темой. Готовое решение «форма заявки на Server Action» с защитой от спама и уведомлением разбирается в отдельном рецепте.

Как проверено7 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
  • запущено, работает

    Первая попытка app/leads/page.tsx (чтение без кэша и Suspense)

    намеренно падающий пример: next build в Next.js 16.4.0 завершился ошибкой blocking-prerender-dynamic, текст ошибки в статье — реальный (сокращён)

  • запущено, работает

    lib/leads.ts, app/leads/page.tsx, lead-list.tsx, lead-stats.tsx, actions.ts, lead-form.tsx

    create-next-app@16.4.0 --yes (cacheComponents + partialPrefetching, React 19.3.0, TS 5.9.3): next build OK (/leads ◐, Revalidate 1h, Expire 1d), tsc --noEmit OK, eslint OK; код статьи считан генератором из файлов проекта

  • запущено, работает

    Статическая оболочка и стриминг

    .next/server/app/leads.html содержит список и заглушку счётчика, но не счётчик; ответ next start: x-nextjs-postponed: 1, Cache-Control private no-store, счётчик дошёл потоком

  • запущено, работает

    Server Action + useActionState (валидация, сохранение полей, updateTag)

    playwright-core + headless Edge против next start: ошибки обоих полей, значения сохранены, aria-invalid=true; корректная заявка первой в списке без перезагрузки, форма очищена, счётчик вырос; POST /leads с заголовком Next-Action; curl после — новая заявка в HTML

  • запущено, работает

    Поведение без updateTag

    updateTag закомментирован, пересборка: заявка записана в data/leads.json, на странице не появилась (кэш hours)

  • запущено, работает

    server-only в клиентском компоненте

    временный bad-client.tsx импортировал readLeads: сборка упала с ошибкой 'server-only' cannot be imported from a Client Component module (вывод реальный, сокращён; рядом Turbopack вывел и ошибки про use cache/cacheLife в клиентском графе); файл удалён

  • запущено, работает

    Упражнение «Удаление заявки» (removeLead, deleteLead, bind)

    в копии проекта: next build, tsc, eslint OK; Playwright — клик Delete убрал заявку без перезагрузки, в файле осталась одна запись

  • не запускалось

    Упражнение «Страница отзывов»

    самостоятельная задача без готового решения в статье — кода нет

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

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

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.