Skip to main content

Як працює рендеринг на стороні клієнта (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-бандл. Тримай межу якомога глибше в дереві.

Швидкий приклад

tsx
// 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 для цього компонента.

Типові помилки

Позначати цілу сторінку як клієнтський компонент без причини.

tsx
// Неправильно: вся сторінка стає 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.

tsx
// Неправильно: крешується під час серверного рендерингу 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-гідратацію без повної ревалідації.

Приклади

Базовий: пошуковий інпут з клієнтським запитом

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

Сервер відправляє статичну сторінку. Пошукове поле гідратується в браузері і отримує результати за запитом. Ніяких перезавантажень сторінки.

Середній: серверні дані передаються в клієнтський фільтр

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

tsx
// 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-ом, який видно одразу. Редактор завантажується після гідратації.

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

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

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

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