Як працює рендеринг на стороні клієнта (CSR) у Next.js
CSR (рендеринг на стороні клієнта, client-side rendering) у Next.js означає, що клієнтський компонент з директивою 'use client' виконується і оновлюється в браузері після того, як сервер надіслав початковий HTML від серверного компонента.
Теорія
TL;DR
- Next.js ніколи не робить чистий CSR, як Create React App. Кожна сторінка починається з серверного компонента, що віддає реальний HTML, а не порожній
<div>. - Клієнтські компоненти (
'use client') гідратуються в браузері й управляють станом, подіями та browser API. hydrateRootз React 18 підключається до існуючих DOM-вузлів без повного перерендерингу.- Використовуй CSR для дій користувача, browser API та real-time UI. Дані для першого завантаження краще отримувати в серверних компонентах.
- Кожен
'use client'модуль збільшує клієнтський JS-бандл. Тримай межу якомога глибше в дереві.
Швидкий приклад
// app/dashboard/page.tsx - Server Component
import ClientDashboard from './ClientDashboard'
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<ClientDashboard /> {/* гідратується в браузері */}
</div>
)
}
// app/dashboard/ClientDashboard.tsx
'use client'
import { useState, useEffect } from 'react'
export default function ClientDashboard() {
const [data, setData] = useState<{ name: string } | null>(null)
useEffect(() => {
fetch('/api/user-data')
.then(res => res.json())
.then(setData)
}, [])
return <div>{data ? `Користувач: ${data.name}` : 'Завантаження...'}</div>
}Сервер надсилає <h1>Dashboard</h1><div>Завантаження...</div>. Браузер отримує це, hydrateRoot підключає React до існуючого DOM, запускається useEffect, приходять дані, і компонент показує "Користувач: John". Два етапи, одна сторінка.
Чим CSR у Next.js відрізняється від чистого SPA
Create React App відправляє порожній <div id="root"></div> і повний JS-бандл. Браузер повинен завантажити, розібрати й виконати все це до того, як покаже хоча б один символ. SEO-боти нічого не бачать. TTFB швидкий, але FCP (First Contentful Paint) повільний.
Next.js так не робить. Навіть якщо компонент позначений 'use client', сторінка все одно починається з серверного компонента, що генерує реальний HTML. Клієнтський компонент також рендериться на сервері для початкового знімка. Браузер отримує цей знімок як HTML, hydrateRoot підключається до існуючих DOM-вузлів, і компонент стає інтерактивним. Ніякого повного перерендерингу. Ніякого порожнього екрану.
Головна ідея: у Next.js CSR завжди накладається поверх серверного HTML.
Коли використовувати
- Завантаження даних за діями користувача (пошук, фільтри, пагінація): CSR з
useEffectіfetch, або TanStack Query для кешування. - Browser API (
localStorage,navigator.geolocation,WebSocket, canvas): їх немає в Node.js, тому компонент має бути клієнтським. - Real-time widgets (чат, live-графіки, сповіщення): клієнтські компоненти з WebSocket або polling.
- SEO-контент і статичні дані: залишай у серверних компонентах. Жодного JS не відправляється в браузер.
- Продуктивність на мобільних: мінімізуй кількість
'use client'меж. Кожна збільшує бандл.
Як гідратація (hydration) працює всередині
Браузер отримує HTML-рядок і JS-бандл від Next.js сервера (Node.js або Edge Runtime). React викликає hydrateRoot(container, element), проходить по існуючому DOM і прикріплює внутрішнє fiber-дерево до вузлів без їх модифікації. Потім запускаються всі useEffect. V8 виконує тільки ті модулі, що доступні через межі 'use client'. Код серверних компонентів до браузера не потрапляє.
З React Server Components і <Suspense> браузер може починати гідратувати частини сторінки по мірі їх надходження, не чекаючи повної відповіді. Клієнтський компонент всередині Suspense-межі може стати інтерактивним ще до того, як сервер завершив надсилання рештки сторінки.
Якщо server-rendered HTML не збігається з тим, що React очікує побачити в браузері, виникає hydration mismatch. React перерендерить цей піддерево з нуля, що скасовує перевагу SSR для цього компонента.
Типові помилки
Позначати цілу сторінку як клієнтський компонент без причини.
// Неправильно: вся сторінка стає CSR, без переваг SSR
'use client'
export default function Page() {
const [data, setData] = useState(null)
useEffect(() => { fetch('/api/data').then(r => r.json()).then(setData) }, [])
return <div>{data?.name}</div>
}
// Правильно: серверний компонент отримує дані, JS не потрібен
export default async function Page() {
const data = await fetch('/api/data').then(r => r.json())
return <div>{data.name}</div>
}Неправильний варіант відправляє порожній HTML на перший запит, шкодить SEO і завантажує зайвий JS у браузер.
Використовувати browser globals поза useEffect.
// Неправильно: крешується під час серверного рендерингу
const [width, setWidth] = useState(window.innerWidth) // window немає в Node.js
// Правильно: безпечне початкове значення, оновлення після монтування
const [width, setWidth] = useState(0)
useEffect(() => setWidth(window.innerWidth), [])Hydration mismatch через недетерміновані значення.
Якщо початковий рендер залежить від Date.now(), Math.random() або стану браузера, сервер і браузер генерують різний HTML. React попереджає і перерендерить. Завжди давай browser-залежному стану безпечне початкове значення і встановлюй реальне в useEffect.
Відсутність обробки помилок у CSR-запитах.
fetch у useEffect без try/catch тихо ламає компонент. Користувач бачить порожню секцію. Додай try/catch і покажи стан помилки, або використай SWR, який обробляє це за замовчуванням.
'use client' занадто високо в дереві.
Бачив команди, що додають 'use client' на рівні layout за замовчуванням. Це означає, що весь піддерево відправляється як клієнтський JS. Перенесення межі вниз до реального інтерактивного leaf-компонента скоротило розмір бандлу вдвічі на одному проекті, де я з цим зіткнувся.
Де використовується
- Vercel Dashboard: CSR для живих графіків і фільтрів через
useEffectі fetch. - Linear: клієнтські компоненти з WebSocket для real-time редагування.
- Clerk:
'use client'для модалок авторизації та роботи зlocalStorage. - Recharts у Next.js: завжди CSR, бо бібліотека потребує
windowі canvas. - Будь-який пошук у реальному часі:
useEffect+AbortControllerдля скасування застарілих запитів.
Питання від інтерв'юерів
Q: Чим гідратація Next.js відрізняється від Create React App?
A: CRA відправляє порожній div, і браузер рендерить все з JS. Next.js відправляє готовий HTML від серверних компонентів, тому контент видно одразу. hydrateRoot підключає React до існуючого DOM без повного перерендерингу.
Q: Що викликає hydration mismatch?
A: Різний HTML на сервері і в браузері для одного компонента. Типові причини: Date.now(), Math.random(), читання window або localStorage при початковому рендерингу. Рішення: переноси browser-логіку в useEffect.
Q: useEffect + fetch або SWR для CSR-даних?
A: useEffect для разових запитів без потреби в кешуванні. SWR або TanStack Query для stale-while-revalidate, дедуплікації і refetch при фокусі. Більшість production-проектів в результаті переходять на SWR.
Q: Чи поширюється 'use client' на всі імпорти всередині файлу?
A: Так. Кожен модуль, що імпортується клієнтським компонентом, також виконується в браузері. Тому межу тримають якомога глибше: 'use client' отримують тільки компоненти, яким справді потрібні стан або події.
Q: Як partial prerendering у App Router v14+ взаємодіє з CSR?
A: Статична оболонка збирається на сервері під час деплою. Клієнтські компоненти всередині Suspense-меж пробивають дірки в цій оболонці та гідратуються самостійно. Динамічні слоти не блокують статичний контент, тому отримуємо миттєвий статичний HTML плюс ізольовану CSR-гідратацію без повної ревалідації.
Приклади
Базовий: пошуковий інпут з клієнтським запитом
// app/search/SearchBox.tsx
'use client'
import { useState } from 'react'
interface Result { id: string; title: string }
export default function SearchBox() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<Result[]>([])
const search = async () => {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
setResults(await res.json())
}
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Пошук..."
/>
<button onClick={search}>Знайти</button>
<ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>
</div>
)
}Сервер відправляє статичну сторінку. Пошукове поле гідратується в браузері і отримує результати за запитом. Ніяких перезавантажень сторінки.
Середній: серверні дані передаються в клієнтський фільтр
// app/problems/page.tsx - Server Component
import { db } from '@/lib/db'
import { ProblemFilter } from './ProblemFilter'
export default async function ProblemsPage() {
const problems = await db.problem.findMany({
select: { id: true, name: true, difficulty: true }
})
return (
<div>
<h1>Задачі</h1>
<ProblemFilter initialProblems={problems} />
</div>
)
}
// app/problems/ProblemFilter.tsx - Client Component
'use client'
import { useState } from 'react'
interface Problem { id: string; name: string; difficulty: number }
export function ProblemFilter({ initialProblems }: { initialProblems: Problem[] }) {
const [difficulty, setDifficulty] = useState<number | null>(null)
const filtered = difficulty
? initialProblems.filter(p => p.difficulty === difficulty)
: initialProblems
return (
<div>
<button onClick={() => setDifficulty(null)}>Всі</button>
<button onClick={() => setDifficulty(1)}>Легкі</button>
<button onClick={() => setDifficulty(2)}>Середні</button>
<button onClick={() => setDifficulty(3)}>Важкі</button>
<ul>{filtered.map(p => <li key={p.id}>{p.name}</li>)}</ul>
</div>
)
}Сервер завантажує дані один раз і передає їх як props. Фільтрація відбувається в пам'яті на клієнті. Ніяких API-запитів при кожному натисканні кнопки.
Просунутий: dynamic import з вимкненим SSR для browser-only компонента
Деякі компоненти використовують API, яких немає на сервері. Редактор коду на базі Monaco, canvas-рендерер або WebSocket-клієнт упадуть при серверному рендерингу, якщо їх імпортувати звичайним способом.
// app/problem/[id]/page.tsx - Server Component
import dynamic from 'next/dynamic'
// ssr: false - цей компонент ніколи не запуститься в Node.js
const CodeEditor = dynamic(
() => import('@/components/CodeEditor'),
{ ssr: false, loading: () => <p>Завантаження редактора...</p> }
)
export default function ProblemPage({ params }: { params: { id: string } }) {
return (
<div>
<h1>Задача {params.id}</h1>
<CodeEditor /> {/* тільки в браузері */}
</div>
)
}
// components/CodeEditor.tsx
'use client'
import { useEffect, useRef } from 'react'
export default function CodeEditor() {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
// Monaco потребує window і canvas - тут це безпечно, ми в браузері
if (ref.current) {
// ініціалізація редактора
}
}, [])
return <div ref={ref} style={{ height: 400 }} />
}ssr: false гарантує, що компонент ніколи не запускається в Node.js. Сторінка все одно відправляє серверний HTML з placeholder-ом, який видно одразу. Редактор завантажується після гідратації.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.