Компоненти сервера React (rsc)
React Server Components (RSC) рендеряться на сервері під час кожного запиту і надсилають браузеру HTML або посилання на клієнтські компоненти, не відправляючи власний JavaScript.
Теорія
TL;DR
- RSC як кухня ресторану: ти отримуєш готову страву (HTML) за столом (у браузері), кухонне приладдя (JS-код) до тебе не їде
- Головна різниця: RSC мають нульовий JS-бандл; клієнтські компоненти відправляють JS для інтерактивності
- RSC може рендерити клієнтські компоненти; клієнтський компонент не може рендерити серверні (але може приймати їх через
children) - За замовчуванням у Next.js App Router - директива не потрібна;
"use client"додаєш тільки для інтерактивності
Базовий приклад
// app/user/[id]/page.tsx - Серверний компонент (за замовчуванням у Next.js)
async function UserPage({ params }: { params: { id: string } }) {
// Прямий доступ до БД - API маршрут не потрібен
const user = await db.user.findUnique({ where: { id: params.id } });
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<LikeButton userId={params.id} /> {/* Клієнтський компонент - відправляється як JS */}
</div>
);
}
// JS для UserPage ніколи не потрапляє до браузера. JS для LikeButton - так.UserPage виконується один раз на сервері для кожного запиту. HTML для h1 і p стрімиться одразу. LikeButton потрапляє до JS-бандлу, бо йому потрібен useState.
Головна різниця
RSC виконуються в Node.js - вони можуть викликати Prisma, читати з fs, використовувати cookies() та headers() з next/headers. Клієнтські компоненти працюють у браузері (і під час SSR), тому їм доступні Web API: window, document і т.д. Практичний результат: статичні частини інтерфейсу не відправляють жодного JavaScript. У продакшн Next.js-додатках це скорочує JS-бандл на 90% і більше для сторінок, де переважає відображення даних.
Серверний vs клієнтський: що кожен може
| Особливість | Серверний компонент | Клієнтський компонент |
|---|---|---|
Директива "use client" | Ні | Так |
| Виконується на | Тільки сервері | Сервер (SSR) + браузер |
| Внесок у JS-бандл | Нуль | Включено |
useState, useEffect | Ні | Так |
Обробники подій (onClick) | Ні | Так |
async компонент | Так | Ні |
| Прямий доступ до БД / файлової системи | Так | Ні - тільки через API |
Браузерні API (window, document) | Ні | Так |
Правила композиції (composition)
Серверний компонент → може рендерити серверні або клієнтські компоненти ✅
Клієнтський компонент → може рендерити тільки клієнтські компоненти ❌
Клієнтський компонент → може приймати серверний компонент через children prop ✅Патерн children - це виходна точка, коли потрібна клієнтська оболонка навколо серверного вмісту:
// layout.tsx - Серверний компонент передає children клієнтському
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<ClientSidebar>
{children} {/* Тут може бути ціле дерево серверних компонентів */}
</ClientSidebar>
);
}React обчислює children на сервері перед тим, як передати результат у ClientSidebar. Клієнтський компонент отримує вже відрендерений результат, а не сам серверний компонент.
Як це працює всередині
Next.js використовує React Server Component Runtime у Node.js. Під час запиту він один раз обходить дерево компонентів і серіалізує вивід RSC у бінарний RSC Payload через react-server-dom-webpack/server. Цей payload містить готовий HTML для серверних компонентів і посилання на модулі для клієнтських. Браузер отримує стрім, рендерить слоти клієнтських компонентів з відповідним JS і пропускає повторне виконання RSC-коду повністю.
Гідрація для серверних компонентів не відбувається. Це принципово відрізняється від класичного SSR, де кожен компонент повторно виконується в браузері для підключення обробників подій.
Коли що використовувати
- Завантаження даних для сторінки продукту, профілю, дашборду: RSC (прямий доступ до БД, нуль JS)
- Інтерактивні кнопки, форми, модальні вікна: клієнтський компонент, вкладений у RSC
- Секрети - API-ключі, облікові дані БД: RSC (до браузера не потрапляє)
- Рендеринг Markdown, підсвічування синтаксису, важкі бібліотеки: RSC (залишаються на сервері)
- WebSocket, canvas, localStorage: клієнтський компонент (тільки браузерні API)
Стрімінг із Suspense
// app/dashboard/page.tsx
import { Suspense } from 'react';
async function SlowAnalytics() {
const data = await fetchAnalytics(); // 2 секунди
return <Chart data={data} />;
}
async function RecentUsers() {
const users = await db.user.findMany({ take: 5 });
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
export default function Dashboard() {
return (
<div>
<h1>Дашборд</h1>
<Suspense fallback={<p>Завантаження аналітики...</p>}>
<SlowAnalytics />
</Suspense>
<Suspense fallback={<p>Завантаження користувачів...</p>}>
<RecentUsers />
</Suspense>
</div>
);
}
// Оболонка HTML стрімиться миттєво. Обидва компоненти завантажуються паралельно.Кожна межа Suspense незалежна. Браузер рендерить оболонку одразу, потім заповнює секції у міру надходження даних. Це напряму впливає на Time to Interactive: сторінка вже придатна до роботи до завантаження всіх даних.
Типові помилки
Завантаження даних у клієнтському компоненті, коли RSC вже має їх:
// Неправильно - подвійний запит, відкриває API
"use client";
async function Likes({ userId }) {
const count = await api.likes(userId);
return <span>{count}</span>;
}
// Правильно - завантаження в RSC, передача через prop
async function LikeSection({ userId }) { // Серверний компонент
const count = await db.likes.count({ where: { userId } });
return <LikeDisplay count={count} />; // Клієнтський компонент отримує дані, а не робить fetch
}Імпорт браузерних модулів у RSC:
// Неправильно - помилка під час збірки
import { useEffect } from 'react'; // У серверному компоненті
import { Chart } from 'chart.js'; // Тільки для браузераПеренеси в файл із "use client" або використай dynamic(() => import(...), { ssr: false }).
Директива "use client" занадто високо в дереві:
// Неправильно - тягне все піддерево в бандл
"use client";
export default function Page() { // Мав бути серверним компонентом
return <div><ProductList /></div>; // ProductList теж стає клієнтським
}Тримай "use client" на листках - тільки на інтерактивних частинах. Команда Vercel зафіксувала 70% регресію продуктивності через необдуманий переклад батьківських компонентів у клієнтські.
Спроба використати стан у RSC:
// Неправильно - RSC не мають стану, рендеряться один раз на запит
function Counter() {
const [count, setCount] = useState(0); // Помилка: хуки не підтримуються в серверних компонентах
return <p>{count}</p>;
}Де використовується в реальних проектах
- Next.js 14+ App Router: сторінки та layouts за замовчуванням є RSC. Vercel Commerce використовує RSC для 100+ сторінок лістингу з нульовим JS для самого лістингу.
- PayloadCMS і Waku використовують RSC для адмін-дашбордів із прямим доступом через Prisma.
- React 19 підтримує RSC поза Next.js через
react-server-dom-webpackв Express. - Патерн "RSC для даних, клієнтський компонент для взаємодії" точно відповідає структурі більшості сучасних продакшн-додатків.
Питання на співбесіді
Q: Чи може RSC читати cookies або заголовки автентифікації?
A: Так. Використовуй cookies() і headers() з next/headers. Вони доступні тільки на сервері і недоступні в клієнтських компонентах.
Q: У якому форматі передається RSC Payload?
A: Це бінарний стрім із посиланнями на модулі, щось на кшталт [ref:0,"div",[ref:1,"h1","John Doe"]]. Клієнт розшифровує їх через webpack module map для рендерингу слотів клієнтських компонентів.
Q: Як тестувати серверний компонент?
A: Через react-test-renderer для отримання статичного дерева. act() не потрібен, бо RSC не мають стану. Для інтеграційних тестів підходить @testing-library/react з замоканим async-компонентом.
Q: Що змінює React 19 порівняно з RSC у Next.js?
A: React 19 додає примітив use для клієнтських компонентів - вони можуть читати серверні дані з Promise. Next.js 15 додав Turbopack, який прискорює збірку RSC приблизно в 10 разів. Сама модель RSC не змінилась.
Q: Поясни оптимізацію flight graph та підйом (hoisting) дерева.
A: Клієнтські компоненти піднімаються вгору дерева і дедублюються в бандлі. Flight graph (серіалізований RSC payload) об'єднує дублікати піддерев на сервері, тому повторювані компоненти на кшталт навігації або футера не відправляються кілька разів. Це відбувається всередині @react-server-dom-webpack під час серіалізації.
Приклади
Базовий: серверний компонент із прямим доступом до БД
// app/products/[id]/page.tsx
import { db } from '@/db';
import { eq } from 'drizzle-orm';
import { products } from '@/db/schema';
import AddToCart from '@/components/AddToCart'; // "use client"
async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.query.products.findFirst({
where: eq(products.id, params.id),
with: { variants: true }
});
return (
<article>
<h1>{product.name}</h1>
<p>${product.price}</p>
<AddToCart productId={product.id} /> {/* Тільки цей компонент відправляє JS */}
</article>
);
}ProductPage не відправляє жодного JavaScript. Запит до БД виконується на сервері. AddToCart - єдиний компонент у JS-бандлі, бо йому потрібен onClick.
Середній: стрімінг дашборду з паралельними запитами
// app/dashboard/page.tsx
import { Suspense } from 'react';
async function Analytics() {
const data = await fetchAnalyticsReport(); // Повільний зовнішній API
return <BarChart data={data} />;
}
async function RecentOrders() {
const orders = await db.select().from(ordersTable).limit(10);
return (
<ul>
{orders.map(o => (
<li key={o.id}>{o.product} - ${o.total}</li>
))}
</ul>
);
}
export default function Dashboard() {
return (
<>
<h1>Дашборд</h1>
<Suspense fallback={<div>Завантаження аналітики...</div>}>
<Analytics />
</Suspense>
<Suspense fallback={<div>Завантаження замовлень...</div>}>
<RecentOrders />
</Suspense>
</>
);
}Analytics і RecentOrders завантажуються паралельно. Оболонка сторінки рендериться миттєво. Кожна секція з'являється, коли її дані готові. Жодний JavaScript не чекає на жоден із запитів на клієнті.
Просунутий: важкі бібліотеки залишаються на сервері
// app/blog/[slug]/page.tsx
import { marked } from 'marked'; // ~50KB - залишається на сервері
import hljs from 'highlight.js'; // ~100KB - залишається на сервері
import ShareButton from '@/components/ShareButton'; // "use client" - потрібен для clipboard API
async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
// Обидві бібліотеки виконуються на сервері і ніколи не потрапляють до браузера
const html = marked(post.content);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: html }} />
<ShareButton url={`/blog/${params.slug}`} /> {/* ~2KB JS */}
</article>
);
}marked і highlight.js разом важать близько 150KB. У CSR-додатку обидві бібліотеки відправляються кожному відвідувачу. Тут вони виконуються один раз на сервері для кожного запиту. Клієнт отримує HTML і ~2KB JS для ShareButton. Це весь бандл для цієї сторінки.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.