З впровадженням архітектури 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' розміщена на найнижчому листку (кнопці). Батьківські дані та важкий парсер виконуються на сервері й не потрапляють у клієнтський 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="uk" suppressHydrationWarning>
<body className="bg-bg-base text-text-primary">
{/* Кореневий макет залишається Server Component! */}
<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'; // Server Component з 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' на початку всіх критичних сервісних файлів.