Skip to main content

Компоненти сервера React (rsc)

React Server Components (RSC) рендеряться на сервері під час кожного запиту і надсилають браузеру HTML або посилання на клієнтські компоненти, не відправляючи власний JavaScript.

Теорія

TL;DR

  • RSC як кухня ресторану: ти отримуєш готову страву (HTML) за столом (у браузері), кухонне приладдя (JS-код) до тебе не їде
  • Головна різниця: RSC мають нульовий JS-бандл; клієнтські компоненти відправляють JS для інтерактивності
  • RSC може рендерити клієнтські компоненти; клієнтський компонент не може рендерити серверні (але може приймати їх через children)
  • За замовчуванням у Next.js App Router - директива не потрібна; "use client" додаєш тільки для інтерактивності

Базовий приклад

tsx
// 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 - це виходна точка, коли потрібна клієнтська оболонка навколо серверного вмісту:

tsx
// 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

tsx
// 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 вже має їх:

tsx
// Неправильно - подвійний запит, відкриває 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:

tsx
// Неправильно - помилка під час збірки import { useEffect } from 'react'; // У серверному компоненті import { Chart } from 'chart.js'; // Тільки для браузера

Перенеси в файл із "use client" або використай dynamic(() => import(...), { ssr: false }).

Директива "use client" занадто високо в дереві:

tsx
// Неправильно - тягне все піддерево в бандл "use client"; export default function Page() { // Мав бути серверним компонентом return <div><ProductList /></div>; // ProductList теж стає клієнтським }

Тримай "use client" на листках - тільки на інтерактивних частинах. Команда Vercel зафіксувала 70% регресію продуктивності через необдуманий переклад батьківських компонентів у клієнтські.

Спроба використати стан у RSC:

tsx
// Неправильно - 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 під час серіалізації.

Приклади

Базовий: серверний компонент із прямим доступом до БД

tsx
// 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.

Середній: стрімінг дашборду з паралельними запитами

tsx
// 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 не чекає на жоден із запитів на клієнті.

Просунутий: важкі бібліотеки залишаються на сервері

tsx
// 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. Це весь бандл для цієї сторінки.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?