У традиційних архітектурах Single Page Application (SPA) та класичному Server-Side Rendering (Next.js Pages Router) існувала фундаментальна проблема: весь JavaScript-код компонентів, відрендерених на сервері, був зобов'язаний завантажуватися в браузер для подальшої гідратації.
У міру масштабування кодової бази розмір клієнтського бандла експоненціально зростає. Браузер змушений завантажувати, парсити та компілювати мегабайти JavaScript навіть для тих частин інтерфейсу, які є абсолютно статичними: навігаційних панелей, футерів, розмітки статей, а також важких серверних бібліотек (парсери Markdown, синтаксичні аналізатори, форматування дат, математичні бібліотеки). Це призводить до деградації ключових метрик Web Vitals — насамперед TTI та INP.
Другою проблемою є мережеві водоспади (Network Waterfalls): клієнтський компонент монтується, ініціює мережевий запит за даними, чекає відповіді від API, потім монтує дочірній компонент, який запускає власний запит.
React Server Components (RSC) в архітектурі Next.js App Router усувають ці обмеження:
У Next.js App Router середовище виконання розділене на два ізольованих, але тісно взаємодіючих світи: Server Module Graph та Client Module Graph.
За замовчуванням всі компоненти є Server Components. Вам не потрібно вказувати спеціальні директиви для їх активації. Розділення графів відбувається в точці оголошення Network Boundary за допомогою директиви 'use client'.
Досліджуйте межу серіалізації React Flight та вплив директиви 'use client' на бандл
Директива 'use client' розміщена на найнижчому листку (кнопці). Батьківські дані та важкий парсер виконуються на сервері й не потрапляють у клієнтський JS-бандл.
Важливо розуміти: компонент, позначений директивою 'use client', все одно рендериться на сервері під час початкового запиту для генерації статичного HTML. Директива 'use client' означає не «рендерити лише в браузері», а «включити цей модуль та всі його дочірні імпорти в клієнтський JavaScript-бандл для подальшої гідратації».
Для опису серверного дерева використовується RSC Flight Payload. Замість розмітки клієнтських компонентів у потік поміщається Client Reference.
Користувач запитує маршрут. Next.js ініціює серверний рендеринг дерева сегментів маршруту (Layouts, Pages).
Сервер виконує асинхронні серверні компоненти, паралельно запитуючи дані з баз даних та зовнішніх API.
React серіалізує дерево в потік Flight: серверні елементи перетворюються на структуру даних, а клієнтські — на Client References плейсхолдери з серіалізованими пропсами.
Сервер миттєво віддає початковий HTML для Fast First Paint, паралельно стрімячи чанки RSC Payload та Suspense-межі через HTTP-стрімінг.
Браузер парсить HTML, завантажує мінімальні JS-чанки для компонентів з 'use client' та гідратує інтерактивні вузли без повторного рендерингу серверної структури.
Чи означає вказання директиви 'use client' на початку файлу, що компонент буде компілюватися та рендеритися виключно в браузері?
Серверні компоненти оголошуються як стандартні функції. Головна відмінність — можливість робити компонент асинхронним (async) і безпосередньо виконувати await промісів.
Відповідно до стандартів Next.js 15+ параметри маршруту (params та searchParams) типізуються як Promise і вимагають обов'язкового await.
import { notFound } from 'next/navigation';
import { db } from '@/lib/db';
interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function UserPage({ params, searchParams }: PageProps) {
// У Next.js 15+ доступ до params та searchParams асинхронний
const { id } = await params;
const query = await searchParams;
const activeTab = typeof query.tab === 'string' ? query.tab : 'overview';
// Прямий запит до бази даних без проміжних fetch/REST API
const user = await db.user.findUnique({
where: { id },
select: { id: true, name: true, email: true, role: true }
});
if (!user) {
notFound();
}
return (
<div className="p-8 max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-text-primary">{user.name}</h1>
<p className="text-text-muted">{user.email}</p>
<div className="mt-4 text-xs font-mono text-accent-cyan">
Активна вкладка: {activeTab}
</div>
</div>
);
}'use client')#Директива 'use client' оголошується першим рядком файлу до будь-яких імпортів або коментарів.
Вона потрібна лише тоді, коли компоненту необхідні:
useState, useReducer, useEffect, useLayoutEffect.onClick, onChange, onSubmit, onKeyDown.window, document, localStorage, navigator, WebSockets.'use client';
import { useState, useTransition } from 'react';
interface CounterProps {
initialCount?: number;
}
export function Counter({ initialCount = 0 }: CounterProps) {
const [count, setCount] = useState(initialCount);
const [isPending, startTransition] = useTransition();
const handleIncrement = () => {
startTransition(() => {
setCount((prev) => prev + 1);
});
};
return (
<div className="flex items-center gap-4">
<button
type="button"
disabled={isPending}
onClick={handleIncrement}
className="px-4 py-2 rounded-lg bg-accent-violet hover:bg-accent-violet/90 text-white font-medium transition-colors disabled:opacity-50"
>
Інкремент: {count}
</button>
{isPending && <span className="text-xs text-text-muted">Оновлення...</span>}
</div>
);
}'use client' проти 'use server'#Одна з найчастіших помилок у спільноті — припущення, що директива 'use server' робить компонент серверним. Це не так:
'use client': Маркує файл як межу клієнтського бандла (Client Boundary).'use server': Оголошує Server Action — серверну функцію для мутації даних (RPC ендпоінт), що викликається через HTTP POST. Вона не створює серверний UI-компонент!Якщо інтерактивному клієнтському компоненту (наприклад, модальному вікну, акордеону або випадаючому меню) потрібно відрендерити всередині себе важкий серверний компонент, не можна імпортувати серверний компонент всередину клієнтського.
Замість цього використовуйте патерн композиції через children або іменовані JSX-слоти:
'use client';
import { useState } from 'react';
interface ModalDialogProps {
triggerLabel: string;
children: React.ReactNode; // Серверний компонент передається зовні!
}
export function ModalDialog({ triggerLabel, children }: ModalDialogProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button
type="button"
onClick={() => setIsOpen(true)}
className="px-3 py-1.5 rounded-md bg-bg-elevated border border-border text-sm"
>
{triggerLabel}
</button>
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-bg-surface border border-border rounded-xl p-6 max-w-lg w-full">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-bold">Деталі</h2>
<button type="button" onClick={() => setIsOpen(false)}>✕</button>
</div>
{/* Тут рендериться серверний слот, залишаючись серверним кодом */}
{children}
</div>
</div>
)}
</>
);
}import { ModalDialog } from '@/components/ModalDialog';
import { HeavyAnalyticsChart } from '@/components/HeavyAnalyticsChart'; // Server Component з важкими бібліотеками
export default async function DashboardPage() {
return (
<main className="p-6">
<h1 className="text-xl font-bold mb-4">Аналітика</h1>
{/* Donut Pattern: передаємо Server Component як children у Client Component */}
<ModalDialog triggerLabel="Відкрити звіт">
<HeavyAnalyticsChart />
</ModalDialog>
</main>
);
}use())#У React 19 та Next.js 15+ через межу серіалізації пропсів можна передавати не лише примітиви, але й нерозв'язані об'єкти Promise. Це дозволяє не блокувати TTFB серверного компонента та розгортати проміс всередині клієнтського інтерфейсу через хук use().
import { Suspense } from 'react';
import { ItemComments } from './ItemComments';
import { fetchCommentsPromise } from '@/lib/data';
export default async function ItemPage({
params
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// Ініціюємо запит БЕЗ await: проміс починає виконуватися у фоні
const commentsPromise = fetchCommentsPromise(id);
return (
<div>
<h1>Картка товару #{id}</h1>
{/* Огортаємо в Suspense для відображення скелетона під час резолву промісу */}
<Suspense fallback={<p className="text-text-muted">Завантаження коментарів...</p>}>
<ItemComments commentsPromise={commentsPromise} />
</Suspense>
</div>
);
}'use client';
import { use } from 'react';
interface Comment {
id: string;
author: string;
text: string;
}
export function ItemComments({
commentsPromise
}: {
commentsPromise: Promise<Comment[]>;
}) {
// React 19 хук use() розгортає проміс напряму всередині клієнтського компонента
const comments = use(commentsPromise);
return (
<div className="space-y-3 mt-4">
<h3 className="font-semibold text-text-primary">Коментарі ({comments.length}):</h3>
{comments.map((comment) => (
<div key={comment.id} className="p-3 rounded-lg border border-border bg-bg-elevated/40">
<span className="font-medium text-accent-cyan text-sm">{comment.author}</span>
<p className="text-sm text-text-muted mt-1">{comment.text}</p>
</div>
))}
</div>
);
}Для запобігання випадковому імпорту серверних утиліт на клієнт встановіть пакет server-only:
pnpm add server-onlyДля захисту конфіденційних об'єктів (DTO з бази даних) використовуйте React 19 Taint API:
import 'server-only';
import { experimental_taintObjectReference } from 'react';
import { db } from '@/lib/db';
export async function getUserAccount(id: string) {
const user = await db.user.findUnique({
where: { id }
});
if (user) {
// Якщо хтось спробує передати об'єкт user цілком у Client Component,
// React негайно викине помилку при збірці або рендері
experimental_taintObjectReference(
'Заборонено передавати сирий об\'єкт користувача в клієнтський компонент для запобігання витоку хешу пароля',
user
);
}
return user;
}Який з наступних типів даних МОЖНА безпечно передати як пропс із Server Component у Client Component через межу React Flight?
Багато розробників за аналогією з 'use client' помилково пишуть 'use server' на початку файлу UI-компонента, намагаючись зробити його серверним.
У App Router всі компоненти за замовчуванням серверні. Директива 'use server' призначена виключно для оголошення Server Actions (асинхронних RPC-ендпоінтів для мутації даних через POST-запити). Розміщення 'use server' у файлі звичайного UI-компонента призведе до помилки компілятора Next.js.
Якщо ви запитуєте запис користувача з бази даних і передаєте об'єкт цілком у Client Component, всі поля запису (включаючи passwordHash, stripeCustomerId, внутрішні токени) будуть серіалізовані у відкритий RSC Flight Payload, доступний будь-якому користувачеві у вкладці Network DevTools.
Рішення: Завжди проєктуйте дані в безпечний DTO з мінімально необхідним набором полів.
// ❌ АНТИПАТЕРН: Весь запис іде у відкритий RSC Flight Payload
const user = await db.user.findUnique({ where: { id } });
return <UserBadge user={user} />;
// ✅ ПРАВИЛЬНО: Серіалізуються лише публічні властивості
return <UserBadge name={user.name} avatarUrl={user.avatarUrl} />;Якщо ви спробуєте напряму імпортувати файл серверного компонента у файл із директивою 'use client', збирач Turbopack автоматично включить його в клієнтський граф модулів. Це призведе до роздування бандла або помилки збірки через неможливість виконати серверні бібліотеки (БД, fs, приватні змінні оточення) у браузері. Для вкладеності використовуйте Donut Pattern (children або слоти).
Якщо в layout.tsx верхнього рівня виконати повільний асинхронний запит (const settings = await getSlowSettings()), браузер користувача чекатиме відповіді білого екрана, оскільки заблокується віддача першого байта (TTFB) всього додатку.
Рішення: Виносьте повільні запити даних глибше в дерево та ізолюйте їх за допомогою <Suspense fallback={<Skeleton />}>.