Граница серверного и клиентского кода, директивы use client и use server, сериализация, действия и модель Next.js App Router.
Серверные компоненты оставляют код и доступ к данным вне клиентского пакета, но требуют явной границы сериализации и доверия.
Вы разделите обязанности сервера и браузера, сократите клиентский JavaScript и защитите серверные функции как сетевые команды.
Понадобятся SSR и гидратация, Suspense, действия, безопасность и асинхронные компоненты. Итог урока — сценарий на Next App Router с загрузкой данных в RSC, небольшой клиентской областью и безопасным изменением.
Серверный компонент (RSC) может быть асинхронным, читать базу данных и файлы и импортировать тяжёлую серверную библиотеку. Его код не отправляется в браузер; результат сериализуется в данные RSC и HTML. Такой компонент не использует состояние, эффекты и API браузера. Это не обычный клиентский компонент с SSR.
Асинхронный компонент читает данные напрямую — без хука, эффекта и промежуточной конечной точки API:
// app/articles/[slug]/page.tsx — выполняется только на сервере
import { markdownToSafeHtml } from '@/server/markdown'; // разбор и очистка остаются здесь
export default async function ArticlePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const article = await db.articles.findBySlug(slug); // прямой доступ к хранилищу
const safeHtml = await markdownToSafeHtml(article.body); // Markdown + allowlist-очистка
return <article dangerouslySetInnerHTML={{ __html: safeHtml }} />;
}Ни db, ни преобразователь Markdown не попадут в браузер: в клиент уйдёт только результат. Тот же импорт в клиентском компоненте либо сломает сборку, либо — что хуже — утащит в пакет строку подключения к базе.
Имя markdownToSafeHtml здесь обозначает явную границу доверия: модуль не только
преобразует Markdown, но и очищает результат по проверенному списку разрешённых
элементов и атрибутов. Простого преобразования Markdown перед
dangerouslySetInnerHTML недостаточно.
Проверьте себя. Перенесите разбор Markdown на сервер.
Частая ошибка. Импортировать клиент базы данных в клиентский компонент.
use client создаёт границу модулейДиректива в модуле отмечает его экспорты как клиентскую точку входа; все косвенно импортированные модули должны работать в браузере. Не ставьте её в каждый файл: границу лучше опустить к интерактивной области. Свойства от сервера к клиенту должны сериализоваться.
Директива в корневой разметке переводит в браузер всё дерево целиком:
// ❌ app/layout.tsx — теперь клиентским становится всё приложение
'use client';
export default function RootLayout({ children }: { children: React.ReactNode }) {
const [menuOpen, setMenuOpen] = useState(false); // ради одного переключателя
return <html lang="ru"><body>{children}</body></html>;
}Границу опускают до самой маленькой интерактивной части, а всё остальное оставляют серверным:
// ✅ app/layout.tsx остаётся серверным
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<body>
<MobileMenu /> {/* только этот модуль помечен 'use client' */}
{children}
</body>
</html>
);
}Директива действует на модуль и все его импорты транзитивно. Поэтому одна ошибка в корне даёт эффект, обратный смыслу RSC: серверных компонентов в приложении не остаётся вовсе.
Проверьте себя. Опустите клиентскую границу со страницы до EditorControls.
Частая ошибка. use client в корневой разметке без причины.
Через границу сервера и клиента проходят только поддерживаемые сериализуемые значения. Экземпляр класса, соединение, произвольная функция и секрет не подходят. Преобразуйте предметный объект в DTO с минимальными полями. Ссылки на серверные функции — специальный протокол фреймворка, а не обычные обратные вызовы.
Сущность хранилища содержит и методы, и лишние поля, часть из которых пользователю видеть нельзя:
// ❌ методы не сериализуются, а ownerEmail и internalNotes утекают в браузер
<DocumentEditor document={await db.documents.find(id)} />DTO перечисляет ровно то, что нужно интерфейсу, — и это перечисление проверяется компилятором:
// src/features/editor/dto.ts
export type DocumentEditorDTO = {
id: string;
title: string;
body: string;
version: number; // нужен для проверки конфликта при сохранении
};
export function toEditorDTO(entity: DocumentEntity): DocumentEditorDTO {
return { id: entity.id, title: entity.title, body: entity.body, version: entity.version };
}Полезное свойство такого преобразования — оно работает как список утверждений о границе: всё, что не перечислено, физически не может оказаться в браузере, даже если появится в сущности позже.
Проверьте себя. Сделайте DocumentEditorDTO.
Частая ошибка. Передать сущность ORM с методами и секретами.
Серверный компонент может импортировать клиентский и передавать ему данные и дочернее содержимое. Клиентский компонент не импортирует серверный напрямую во время выполнения, но может получить уже сформированный серверный элемент через свойства или children по правилам фреймворка. Так серверная работа остаётся внутри интерактивного каркаса.
Приём выглядит так: интерактивная оболочка клиентская, а её содержимое сформировано на сервере:
// app/documents/[id]/page.tsx — серверный компонент
export default async function DocumentPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const details = await db.documents.findDetails(id);
return (
// Collapsible — клиентский (состояние раскрытия), children — серверная разметка
<Collapsible title="Детали документа">
<DocumentDetails details={details} />
</Collapsible>
);
}// src/features/documents/Collapsible.tsx
'use client';
export function Collapsible({ title, children }: { title: string; children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<section>
<button type="button" onClick={() => setOpen((value) => !value)}>{title}</button>
{open && children} {/* содержимое уже готово, повторный запрос не нужен */}
</section>
);
}children здесь — уже сериализованный результат серверной отрисовки, а не функция. Альтернатива — перенести загрузку деталей в браузер ради одного переключателя — добавила бы запрос, состояние загрузки и обработку ошибок на пустом месте.
Проверьте себя. Передайте серверные детали в раскрывающийся клиентский компонент.
Частая ошибка. Перенести владельца данных в браузер ради одного переключателя.
use server отмечает асинхронные функции, вызываемые клиентом по специальной ссылке. Функция, переданная в action формы, становится серверным действием. Проверка данных, авторизация и идемпотентность обязательны. Возвращайте безопасный сериализуемый результат, обновляйте кеш и маршруты по правилам фреймворка и не раскрывайте стек.
Полноценное действие содержит четыре проверки до предметной операции — и ни одна не лишняя:
// app/documents/actions.ts
'use server';
export async function saveDocument(_prev: SaveState, formData: FormData): Promise<SaveState> {
const session = await requireSession(); // 1. кто вызвал
const parsed = SaveSchema.safeParse(Object.fromEntries(formData)); // 2. что прислали
if (!parsed.success) return { kind: 'invalid', message: 'Проверьте поля' };
const document = await db.documents.find(parsed.data.id);
if (document?.ownerId !== session.userId) { // 3. право на объект
return { kind: 'forbidden', message: 'Документ недоступен' };
}
if (document.version !== parsed.data.version) { // 4. конфликт версий
return { kind: 'conflict', server: toEditorDTO(document) };
}
await db.documents.update(document.id, parsed.data.body);
revalidatePath(`/documents/${document.id}`); // после транзакции
return { kind: 'saved' };
}Клиент читает результат через useActionState и показывает конкретный исход, а не общее «что-то пошло не так». Обратите внимание, что revalidatePath вызывается после успешной записи: обновление кеша до транзакции показало бы пользователю неподтверждённые данные.
Проверьте себя. Сохраните document с session/version.
Частая ошибка. Считать действие закрытым только потому, что в интерфейсе нет ссылки.
Фреймворк может объединять одинаковые чтения внутри запроса (дедупликация) и кешировать данные между запросами по заданному правилу. Не смешивайте устранение дублей запроса с долговременным кешем. Ключ приватных данных включает область авторизации либо данные помечаются no-store. После успешного изменения обновляйте теги и маршруты, а не до транзакции.
Матрица кеширования отвечает на два независимых вопроса: можно ли делить данные и на сколько:
// src/server/data.ts
import { cache } from 'react';
import { cacheLife, cacheTag } from 'next/cache';
// публичный справочник: общий кеш Cache Components и тег для обновления
export async function getCurrencies() {
'use cache';
cacheLife('hours');
cacheTag('currencies');
return fetchCurrencies();
}
// приватный документ: дедупликация внутри запроса, но без общего кеша
export const getDocument = cache(async (id: string, orgId: string) => {
return db.documents.find(id, { orgId }); // orgId в ключе обязателен
});React cache устраняет повторные чтения в рамках одного серверного запроса —
это не хранилище между пользователями. Директива 'use cache' в Next.js 16
создаёт более долгоживущую запись Cache Components, поэтому в общий кеш
помещают только данные с явно выбранной областью видимости. Приватные данные
либо остаются в кеше запроса, либо получают полный ключ авторизации. Ключ по
одному documentId без организации — это выдача документа чужому арендатору
при первом же совпадении идентификатора.
Проверьте себя. Опишите матрицу кеширования публичных и приватных данных.
Частая ошибка. Общий ключ кеша только по documentId, без организации.
Promise от сервера к клиентуСерверный компонент может запустить Promise и передать его клиентскому компоненту, который прочитает результат через use под Suspense. Работа начнётся раньше и не заблокирует критичное серверное содержимое. Результат должен сериализоваться, а ошибка — попадать в границу. Не передавайте неограниченный поток или секрет.
Отсутствие await здесь — не упущение, а суть приёма:
// app/articles/[slug]/page.tsx
export default async function ArticlePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const article = await db.articles.findBySlug(slug); // ждём: без этого страницы нет
const commentsPromise = db.comments.listBySlug(slug); // не ждём: передаём Promise
return (
<article>
<ArticleBody article={article} />
<Suspense fallback={<CommentsSkeleton />}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
</article>
);
}// src/features/comments/Comments.tsx
'use client';
export function Comments({ commentsPromise }: { commentsPromise: Promise<CommentDTO[]> }) {
const comments = use(commentsPromise); // приостановит только эту границу
return <ul>{comments.map((item) => <li key={item.id}>{item.body}</li>)}</ul>;
}Запрос комментариев стартовал одновременно со статьёй, но каркас страницы ушёл в браузер, не дожидаясь его. Если поставить await перед db.comments.listBySlug, весь HTML будет ждать самые медленные данные — а это ровно то, чего мы избегали.
Проверьте себя. Передайте commentsPromise ниже критичного текста статьи.
Частая ошибка. Ожидать вторичные данные до отправки каркаса.
Возможности RSC в React 19 стабильны для приложений, но низкоуровневые API для авторов сборщиков и фреймворков не полностью следуют semver. Курс использует поддерживаемый Next App Router, а не самописный сборщик RSC. Обновления фреймворка и React проходят тесты совместимости и проверку уведомлений безопасности.
Разница между поддерживаемым и внутренним API видна по имени пакета:
// ❌ внутренний протокол: сломается в минорной версии без предупреждения
import { createFromFetch } from 'react-server-dom-webpack/client';
// ✅ публичный контракт фреймворка
import { revalidatePath } from 'next/cache';Поэтому версии фиксируют вместе — React, react-dom и фреймворк образуют один совместимый набор:
{
"dependencies": {
"react": "19.2.8",
"react-dom": "19.2.8",
"next": "16.2.12"
}
}Этот совместимый набор проверен для редакции курса от 28 июля 2026 года. Перед копированием версий в новый проект сверяйте актуальные исправления безопасности: RSC-граница обрабатывает недоверенный ввод, поэтому устаревший патч нельзя считать безопасным только из-за совпадения минорной версии. При обновлении набора прогоняют сквозные тесты серверных действий, проверяют расхождения гидратации и читают уведомления безопасности.
Проверьте себя. Зафиксируйте версии и список проверок перед обновлением.
Частая ошибка. Импортировать внутренние API react-server-dom напрямую.
Реализуйте страницу документа: асинхронный серверный компонент читает хранилище, клиентский редактор получает сериализуемый DTO, серверная функция сохраняет с проверкой прав и версии, useActionState показывает конфликт. Не передавайте через границу секреты, классы и функции.
Работа готова, когда клиентский пакет не содержит серверную библиотеку, граница сериализуема, а действие проверяет право на объект и корректно обрабатывает предварительное изменение при конфликте.
Далее: Архитектура эксплуатации и наблюдаемость