Skip to main content

Як працюють серверні компоненти (rsc) у Next.js

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

Теорія

TL;DR

  • Server Components виконуються один раз на запит, на сервері. Їхній код ніколи не потрапляє до клієнтського бандлу.
  • Аналогія: кухня, яка готує страву і подає її через ліфт. Кухарі й рецепти не приходять до твого столу.
  • Результат надходить до браузера як RSC payload - бінарний стрім, а не JavaScript.
  • Головне правило вибору: отримуєш дані, маєш секрети, використовуєш важкі бібліотеки? Server Component. Потрібен стан, обробники подій, браузерне API? Client Component з 'use client'.
  • У Next.js App Router (13+) всі компоненти за замовчуванням є серверними.

Короткий приклад

tsx
// app/page.tsx - Server Component (без 'use client' = сервер за замовчуванням) import { db } from '@/lib/db' export default async function ProblemsPage() { // Виконується на сервері. Імпорт db ніколи не потрапляє до браузера. const problems = await db.problem.findMany() return <ul>{problems.map(p => <li key={p.id}>{p.name}</li>)}</ul> }
tsx
// app/components/FilterButton.tsx - Client Component 'use client' import { useState } from 'react' export function FilterButton({ onFilter }) { const [active, setActive] = useState(false) return ( <button onClick={() => { setActive(!active); onFilter(active) }}> Фільтр </button> ) }

Серверний компонент отримує дані і рендерить HTML. Клієнтський обробляє кліки. Без перетину.

Головна різниця від SSR

SSR (Server-Side Rendering) рендерить все дерево React на сервері один раз і надсилає повний JavaScript-бандл для гідратації. RSC - інша модель: серверні частини дерева назавжди залишаються на сервері, не надсилають код клієнту і перерендерюються на сервері при навігації. Бібліотека для графіків на 200 КБ залишається на сервері. Браузер отримує тільки результат.

Правила вибору

  • Отримати дані з БД або файлової системи - Server Component. Прямий await db.query... без API-прошарку.
  • Доступ до змінних середовища або секретів - Server Component. process.env.DB_URL ніколи не виточе.
  • Важкі залежності (обробка зображень, парсер Markdown) - Server Component. Зменшує клієнтський бандл.
  • Стан, хуки або обробники подій - Client Component. Додаєш 'use client' на початку файлу.
  • Браузерне API (localStorage, геолокація) - тільки Client Component.
  • Інтерактивні форми, real-time UI - Client Component.

Таблиця порівняння

АспектServer Components (за замовчуванням)Client Components ('use client')
ВиконанняNode.js, один раз на запитБраузер, після завантаження JS бандлу
Вплив на бандлНульовий - тільки RSC payloadПовний код компонента + залежності
Доступ до данихПрямо: БД, файли, змінні середовищаЧерез props від сервера або fetch до API
Хуки / обробники подійНедоступніПовна підтримка
Повторний рендер на клієнтіНіколиПри зміні стану або props
Де застосовуватиДашборди з даними, спискиФорми, модалки, інтерактивні острівці

Як RSC працює під капотом

Next.js виконує Server Components у Node.js під час обробки запиту. Результат серіалізується у RSC payload: бінарний стрім із інструкціями для React runtime у браузері. Payload містить відрендерений HTML для серверних частин і маркери-"дірки" (holes) там, де мають бути гідратовані Client Components. Браузер отримує HTML і RSC payload, одразу вставляє статичний контент, а потім гідратує тільки клієнтські острівці.

При навігації на новий маршрут Next.js запитує RSC payload для цього маршруту. Server Components перерендерюються на сервері. Client Components зберігають свій стан. Код серверних компонентів браузер не завантажує ніколи.

На практиці я помітив: команди, що переходять з Pages Router, за звичкою додають 'use client' скрізь. Результат - клієнтський бандл, ідентичний тому, що був до міграції. За замовчуванням все серверне, і ти переходиш на клієнт тільки тоді, коли справді потрібна інтерактивність.

Поширені помилки

1. Використання useState в Server Component

tsx
// Неправильно - помилка збірки в Next.js 13+ export default function Page() { const [count, setCount] = useState(0) // Хуки не існують на сервері return <div>{count}</div> }

Вирішення: перенеси стан у дочірній Client Component, передай початкові дані через props.

2. Отримання чутливих даних у Client Component

tsx
// Неправильно - ключ API видно у бандлі браузера 'use client' export function Users() { useEffect(() => { fetch(`/api/users?key=${process.env.NEXT_PUBLIC_SECRET}`) // витік }, []) }

Отримуй дані у Server Component і передавай результат через props. Секрети залишаються на сервері.

3. 'use client' скрізь

Позначення компонента як 'use client' затягує всі його імпорти до клієнтського бандлу. Бібліотека для графіків на 200 КБ стає 200 КБ клієнтського JavaScript. За замовчуванням - Server. 'use client' тільки для найменшого інтерактивного листка.

4. Спроба імпортувати Server Component у Client Component

tsx
// Неправильно - клієнтські файли не можуть імпортувати серверні модулі 'use client' import { ServerCard } from './server-card' // порушує серверний кордон

Передавай відрендерений сервером контент через children або props.

Де зустрічається на практиці

  • Vercel dashboard: Server Components отримують таблиці даних через Prisma, Client Components обробляють фільтри.
  • T3 Stack (create-t3-app): серверні сторінки отримують дані з Clerk або tRPC, клієнтські обробляють оптимістичні оновлення.
  • Shadcn/UI + Next.js 14: сервер рендерить <Table> з даними БД, клієнт керує станом <Dialog>.
  • Будь-який платіжний дашборд: списки транзакцій тільки на сервері, Stripe SDK не потрапляє до браузера.

Питання на співбесіді

Q: Що таке RSC payload?
A: Бінарний стрім, який Next.js надсилає разом з HTML. Містить відрендерений результат Server Components і маркери-"дірки" для клієнтських меж. React runtime у браузері читає його для оновлення DOM без повторного завантаження серверного коду.

Q: Чим RSC відрізняється від SSR?
A: SSR надсилає HTML плюс повний JavaScript-бандл для гідратації всього дерева на клієнті. RSC стрімить частковий payload без JS для серверних частин. Server Components ніколи не гідратуються. Гідратуються тільки Client Components.

Q: Чи можуть Server Components використовувати React context?
A: Ні. Context потребує клієнтського runtime. Дані між Server Components передаються через props або отримуються повторно в кожному компоненті (Next.js автоматично дедуплікує однакові fetch-запити).

Q: Як працює кешування в Server Components?
A: Next.js кешує fetch-запити за замовчуванням. Використовуй cache: 'force-cache' для статичних даних або unstable_cache для не-fetch-джерел, наприклад прямих запитів до БД.

Q: (Senior) У вкладеному дереві Suspense є повільна внутрішня межа і швидка зовнішня. Як RSC це обробляє?
A: Next.js стрімить RSC payload частинами. Швидка зовнішня межа вирішується першою і надходить до браузера, який рендерить fallback для повільної внутрішньої. Коли повільна межа вирішується на сервері, Next.js стрімить chunk із заміною. React runtime у браузері замінює fallback без повного перерендеру сторінки. Жодного клієнтського JavaScript для серверних частин.

Приклади

Базовий: сервер отримує дані, клієнт фільтрує

tsx
// app/problems/page.tsx (Server Component) import { db } from '@/lib/db' import { ProblemList } from './problem-list' export default async function ProblemsPage() { const problems = await db.problem.findMany({ orderBy: { difficulty: 'asc' } }) // Передаємо серіалізовані дані до клієнтського компонента return <ProblemList problems={problems} /> }
tsx
// app/problems/problem-list.tsx (Client Component) 'use client' import { useState } from 'react' export function ProblemList({ problems }) { const [filter, setFilter] = useState('all') const filtered = filter === 'all' ? problems : problems.filter(p => p.difficulty === Number(filter)) return ( <div> <select onChange={e => setFilter(e.target.value)}> <option value="all">Усі</option> <option value="1">Легкі</option> <option value="2">Середні</option> <option value="3">Важкі</option> </select> <ul>{filtered.map(p => <li key={p.id}>{p.name}</li>)}</ul> </div> ) }

Сервер отримує всі задачі один раз. Фільтрація виконується у браузері на вже отриманих даних. Жодного додаткового API-запиту при зміні фільтра.

Середній: Server Component як children

Client Components не можуть імпортувати Server Components. Але можуть отримувати їх через children. Це дозволяє будувати інтерактивні обгортки, які всередині зберігають переваги серверного рендерингу.

tsx
// components/accordion.tsx (Client Component - керує відкриттям/закриттям) 'use client' import { useState } from 'react' export function Accordion({ title, children }) { const [open, setOpen] = useState(false) return ( <div> <button onClick={() => setOpen(!open)}>{title}</button> {open && children} </div> ) }
tsx
// app/faq/page.tsx (Server Component - отримує дані, використовує клієнтську обгортку) import { Accordion } from '@/components/accordion' import { db } from '@/lib/db' export default async function FAQ() { const items = await db.faq.findMany() return ( <div> {items.map(item => ( <Accordion key={item.id} title={item.question}> <p>{item.answer}</p> {/* Відрендерений сервером контент */} </Accordion> ))} </div> ) }

<p>{item.answer}</p> рендерить Server Component. Accordion керує станом перемикача на клієнті. Дані FAQ ніколи не потрапляють до клієнтського бандлу.

Просунутий: стрімінг з Suspense

Коли одна частина сторінки повільна (складний запит до БД на 2 секунди), можна стрімити швидкі частини одразу, а повільну - коли вона буде готова.

tsx
// app/dashboard/page.tsx import { Suspense } from 'react' import { TeamStats } from './team-stats' // повільно - складна агрегація в БД import { QuickLinks } from './quick-links' // швидко - статичні або кешовані дані export default function DashboardPage() { return ( <div> <Suspense fallback={<p>Завантаження статистики...</p>}> <TeamStats /> {/* Стрімиться коли готово */} </Suspense> <QuickLinks /> {/* Стрімиться одразу */} </div> ) }
tsx
// app/dashboard/team-stats.tsx (Server Component) import { db } from '@/lib/db' export async function TeamStats() { const stats = await db.stats.aggregate({ _sum: { score: true } }) return <div>Загальний рахунок команди: {stats._sum.score}</div> }

QuickLinks з'являється одразу. TeamStats показує "Завантаження..." до завершення запиту, потім сервер стрімить реальний контент. Жодного JavaScript в браузері для жодного з компонентів.

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

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

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

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