С внедрением архитектуры React Server Components (RSC) разработчики столкнулись с принципиально новым вызовом: разделением кодовой базы на серверный и клиентский миры в рамках единого визуального дерева интерфейса.
В классическом React компонентная композиция была тривиальной: любой компонент мог свободно импортировать любой другой компонент. В Next.js App Router действуют жесткие физические границы сети (Network Boundary). Ошибки в архитектуре приводят к:
fs, базы данных, приватные токены) внутри браузерного бандла.useState, useEffect) или React Context без потери серверных преимуществ.Патерны композиции — это строгий набор архитектурных решений, позволяющий бесшовно чередовать серверные и клиентские компоненты (Interleaving), удерживать размер клиентского бандла около нуля и передавать данные без нарушения правил изоляции RSC.
В Next.js корень дерева компонентов (layout.tsx, page.tsx) всегда является серверным.
Правило компилятора Turbopack звучит категорично: директива 'use client' заражает всё поддерево импортов. Если вы импортируете компонент в файл с 'use client', этот компонент (и все его зависимости) автоматически компилируются в клиентский бандл.
Исследуйте границу сериализации React Flight и влияние директивы 'use client' на бандл
Директива 'use client' размещена на самом нижнем интерактивном листе (кнопке). Родительские данные и тяжелый Markdown-парсер исполняются на сервере и не попадают в JS-бандл браузера.
Главный принцип архитектора Fullstack Agentic Lab: сдвигать 'use client' как можно ближе к листьям дерева, а если серверный контент должен оказаться внутри клиентского контейнера — использовать Donut Pattern (Паттерн 'Бублик').
Разрешено напрямую через import. Код выполняется на сервере, 0 KB в JS-бандле браузера.
Разрешено напрямую через import. Пропсы обязаны быть сериализуемыми (JSON-типы, Promise, JSX-слоты).
Разрешено напрямую через import. Стандартный React в браузере.
Строго запрещено. Прямой импорт приведёт к принудительной компиляции серверного компонента в клиентский бандл.
Разрешено. Паттерн Donut Pattern: серверный компонент передается в виде готового ReactNode дескриптора снаружи.
Что произойдет, если клиентский компонент (с директивой 'use client') напрямую импортирует серверный компонент: import { ServerFeed } from './ServerFeed'?
Когда клиентскому интерактивному компоненту (например, раздвижному сайдбару с анимацией) требуется отобразить внутри себя тяжелые серверные данные, мы используем пропс children:
'use client';
import { useState } from 'react';
interface CollapsibleSidebarProps {
children: React.ReactNode; // Серверный слот
}
export function CollapsibleSidebar({ children }: CollapsibleSidebarProps) {
const [isOpen, setIsOpen] = useState(true);
return (
<div className="flex">
<aside className={`transition-all duration-300 ${isOpen ? 'w-64' : 'w-16'}`}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="p-2 border border-border rounded bg-bg-elevated text-xs mb-4"
>
{isOpen ? 'Свернуть' : 'Развернуть'}
</button>
{/* Здесь рендерится готовый серверный узел. Код БД не попадает в бандл! */}
<div className={isOpen ? 'block' : 'hidden'}>
{children}
</div>
</aside>
</div>
);
}import { CollapsibleSidebar } from '@/components/CollapsibleSidebar';
import { ServerNavigationTree } from '@/components/ServerNavigationTree'; // Серверная библиотека + запрос к БД
export default async function DashboardPage() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold mb-6">Панель управления</h1>
{/* Donut Pattern: передаем серверный компонент в клиентскую оболочку */}
<CollapsibleSidebar>
<ServerNavigationTree />
</CollapsibleSidebar>
</main>
);
}Если интерфейс требует разделения на несколько независимых зон (например, шапка, боковая панель и контентная область), клиентский компонент объявляет Named Slots (Именованные слоты):
'use client';
import { useState } from 'react';
interface SplitLayoutProps {
sidebarSlot: React.ReactNode;
contentSlot: React.ReactNode;
}
export function SplitLayout({ sidebarSlot, contentSlot }: SplitLayoutProps) {
const [activePane, setActivePane] = useState<'both' | 'content'>('both');
return (
<div className="flex gap-6 min-h-screen">
{activePane === 'both' && (
<aside className="w-1/3 border-r border-border p-4">
<button
type="button"
onClick={() => setActivePane('content')}
className="text-xs text-accent-cyan mb-3 block"
>
Фокус на контенте →
</button>
{sidebarSlot}
</aside>
)}
<section className="flex-1 p-4">
{activePane === 'content' && (
<button
type="button"
onClick={() => setActivePane('both')}
className="text-xs text-accent-cyan mb-3 block"
>
← Показать сайдбар
</button>
)}
{contentSlot}
</section>
</div>
);
}Хук useContext недоступен в серверных компонентах. Однако популярные библиотеки (Next-Themes, TanStack Query, Zustand) требуют оборачивания дерева в Context Provider.
Решение: провайдер выносится в изолированный клиентский модуль, принимающий {children}:
'use client';
import { ThemeProvider } from 'next-themes';
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
{/* Все вложенные серверные компоненты сохраняют свой серверный статус! */}
{children}
</ThemeProvider>
);
}import { AppProviders } from '@/components/providers/AppProviders';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru" suppressHydrationWarning>
<body className="bg-bg-base text-text-primary">
{/* Корневой серверный макет остается серверным! */}
<AppProviders>
{children}
</AppProviders>
</body>
</html>
);
}Вместо того чтобы объявлять всю страницу клиентской из-за одного поля ввода поиска, изолируйте интерактивность в компактный компонент-лист:
'use client';
import { useTransition } from 'react';
import { useRouter, usePathname } from 'next/navigation';
export function SearchInput({ defaultValue = '' }: { defaultValue?: string }) {
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
const handleSearch = (term: string) => {
const params = new URLSearchParams(window.location.search);
if (term) {
params.set('q', term);
} else {
params.delete('q');
}
startTransition(() => {
router.replace(`${pathname}?${params.toString()}`);
});
};
return (
<div className="relative">
<input
type="search"
defaultValue={defaultValue}
placeholder="Поиск по документации..."
onChange={(e) => handleSearch(e.target.value)}
className="w-full px-4 py-2 rounded-lg bg-bg-elevated border border-border text-sm focus:outline-none focus:border-accent-cyan"
/>
{isPending && <span className="absolute right-3 top-2.5 text-xs text-text-muted animate-spin">⏳</span>}
</div>
);
}import { SearchInput } from '@/components/SearchInput';
import { HeavyArticleList } from '@/components/HeavyArticleList'; // Серверный компонент с markdown-парсером
interface PageProps {
searchParams: Promise<{ q?: string }>;
}
export default async function DocsPage({ searchParams }: PageProps) {
const { q } = await searchParams;
return (
<div className="max-w-4xl mx-auto p-8 space-y-6">
<h1 className="text-3xl font-bold">База знаний</h1>
{/* Интерактивный клиентский лист */}
<SearchInput defaultValue={q} />
{/* Серверный список: 0 байт JS в клиентском бандле */}
<HeavyArticleList query={q ?? ''} />
</div>
);
}Клиентские кнопки могут выполнять серверные мутации без необходимости ручного создания Route Handlers (/api/...) благодаря передаче Server Actions:
'use client';
import { useTransition } from 'react';
interface ActionButtonProps {
action: () => Promise<void>; // Server Action передается в пропс!
label: string;
}
export function ActionButton({ action, label }: ActionButtonProps) {
const [isPending, startTransition] = useTransition();
return (
<button
type="button"
disabled={isPending}
onClick={() => startTransition(async () => { await action(); })}
className="px-4 py-2 rounded-lg bg-accent-violet text-white text-sm font-medium hover:bg-accent-violet/90 transition-colors disabled:opacity-50"
>
{isPending ? 'Выполняется...' : label}
</button>
);
}Почему серверный компонент, переданный как children в клиентский компонент, НЕ перерисовывается и не теряет свой серверный статус при вызове setState в клиентском родителе?
Серверные компоненты не могут передавать обычные синхронные функции в пропсах клиентским компонентам, так как функции не сериализуются в формат JSON/RSC Flight Payload (TypeError: Functions cannot be passed directly to Client Components).
Решение:
'use server' (сериализуется в виде RPC-дескриптора).// ❌ ОШИБКА: Синхронная стрелочная функция в Server Component
// return <ClientButton onClick={() => console.log('delete')} />;
// ✅ ПРАВИЛЬНО: Использовать Server Action для серверных мутаций
// app/actions.ts:
// 'use server';
// export async function deleteItemAction(id: string) { await db.item.delete({ where: { id } }); }
// app/page.tsx:
// <ClientButton action={deleteItemAction} />Если в клиентском компоненте импортировать простую кнопку из общего components/index.ts (import { Button } from '@/components'), сборщик может включить тяжелые серверные компоненты (например редактор Monaco), реэкспортируемые в этом же файле, в клиентский бандл.
Решение: В клиентских файлах используйте точечные импорты (import { Button } from '@/components/ui/Button') либо настраивайте optimizePackageImports в next.config.ts.
Многие разработчики добавляют 'use client' прямо в app/layout.tsx, чтобы обернуть сайт в ThemeProvider или SessionProvider. Это фатальная архитектурная ошибка, которая превращает всё приложение в клиентский Single Page Application, полностью отключая преимущества RSC.
Решение: Создавайте отдельный изолированный клиентский компонент AppProviders.tsx со слотом {children} и используйте его внутри серверного RootLayout.
Если модуль обращается к секретам базы данных, но не защищён директивой import 'server-only', разработчик в команде может случайно импортировать его в клиентский компонент. Сборщик не выдаст ошибки, но секреты утекут в открытый браузерный код.
Решение: Установите pnpm add server-only и добавляйте import 'server-only' в начало всех критических сервисных файлов.