В App Router компоненты по умолчанию серверные: они могут читать базу и секреты напрямую, а в браузер уходит только результат. Клиентским компонент становится директивой "use client" — она нужна для состояния, обработчиков событий и браузерных API. В Next.js 16 с Cache Components данные по умолчанию не кэшируются: их либо кэшируют через "use cache" с cacheLife и cacheTag, либо оборачивают компонент в <Suspense> и догружают потоком. Изменения делают через Server Actions — функции с "use server", которые после записи вызывают updateTag и сами проверяют ввод и права пользователя.
После материала вы сможете
- решить, какой компонент оставить серверным, а какой сделать клиентским, и провести между ними границу через
"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 г.
Содержание
Серверные и клиентские компоненты: где выполняется код
В уроке об 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.
Практика урока — страница заявок 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 и базу, проверяет права и решает, какие поля отдать в рендер.
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;
}Функции getLeads с "use cache" разберём в следующем разделе. Сначала попробуем самый прямой путь — прочитать заявки в странице:
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>
);
}$ 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: nullnext 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 директива стоит вместе с двумя вызовами — их документация рекомендует добавлять всегда:
cacheLife("hours")
Срок жизни записи. Профиль
hours: клиент считает данные свежими 5 минут (stale), сервер обновляет запись в фоне через час (revalidate), а через сутки без запросов она истекает (expire). БезcacheLifeдействует профильdefault.cacheTag("leads")
Метка, по которой запись можно сбросить по требованию — например, сразу после добавления заявки.
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 год
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>
);
}$ 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Проверить, что попало в статическую оболочку, можно прямо в результатах сборки — Next.js хранит пререндеренный HTML в .next/server/app/:
$ 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) — страница приходит частями.
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>
);
}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 на текущий адрес и вернёт новые данные и обновлённый интерфейс за один запрос.
"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.` };
}Читаем и нормализуем ввод
formData.getвозвращаетFormDataEntryValue | null. Приводим к строке и обрезаем пробелы — клиенту доверять нельзя, даже если в форме естьrequired.Ожидаемые ошибки — значением
Документация советует не бросать исключения при ошибках валидации, а возвращать их как результат. Введённые значения тоже возвращаем, чтобы пользователь не набирал их заново.
Пишем и сбрасываем кэш
updateTag("leads")немедленно делает запись с этим тегом устаревшей: следующий рендер прочитает свежие данные, и пользователь сразу увидит свою заявку. ВызыватьupdateTagможно только внутри Server Action.
"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 в клиентский компонент:
$ 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.