Skip to main content

Як працює серверна рендеринг (SSR) у Next.js

SSR (server-side rendering, серверний рендеринг) у Next.js генерує повний HTML на сервері для кожного вхідного запиту, використовуючи свіжі дані, і лише потім надсилає результат браузеру.

Теорія

TL;DR

  • SSR як ресторанна кухня: готує страву щоразу за новим замовленням. SSG заздалегідь готує пачку і роздає.
  • Кожен запит запускає отримання свіжих даних і повний рендер HTML на сервері.
  • В App Router достатньо cache: 'no-store' у fetch або викликати cookies() / headers() - Next.js сам переводить сторінку в SSR-режим.
  • SSR - для персональних або змінних даних. Все, що можна кешувати, краще обслуговувати через SSG або ISR.

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

tsx
// app/dashboard/page.tsx async function Dashboard() { const res = await fetch('https://api.example.com/user-data', { cache: 'no-store' // свіжі дані на кожен запит }); const data = await res.json(); return <div>Баланс: {data.balance} грн</div>; }

На кожен запит Next.js виконує цю функцію на сервері, отримує актуальні дані, рендерить <div>Баланс: 150 грн</div> і надсилає готовий HTML браузеру. JavaScript завантажується пізніше - React гідратує сторінку і підключає обробники подій.

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

SSR відрізняється від SSG одним: моментом генерації. SSG будує HTML раз під час деплою і віддає з CDN миттєво. SSR будує HTML під кожен запит на сервері. Перший байт приходить повільніше, але дані завжди актуальні. Порівняно з CSR (рендеринг на клієнті) - SSR надсилає реальний контент у початковому HTML. Пошукові боти бачать його одразу, а користувач отримує змістовний перший екран без очікування JavaScript.

Коли використовувати

  • Персональні дані - профіль, налаштування, дашборд зі session-даними, все що за логіном.
  • Дані в реальному часі - ціни акцій, рахунки матчів, залишки товарів, де важлива свіжість.
  • Контент залежить від авторизації - сторінки, що читають cookies або заголовки щоб вирішити що показати.
  • Статичний або рідко змінний контент - обери SSG, він швидший і не навантажує сервер.
  • Великий трафік, дані можуть бути на хвилину старішими - ISR з revalidate підходить краще.

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

ХарактеристикаSSRSSGCSR
Дані отримуютьсяНа кожен запит (сервер)Під час збіркиПісля завантаження (клієнт)
Початковий HTMLПовністю готовийПре-зібраний статичний файлПорожня оболонка
TTFBВищий (обчислення на сервері)Найнижчий (CDN)Низький, але без контенту
SEOВідміннеВідміннеПогане без pre-rendering
Навантаження на серверНа кожен запитВідсутнє після збіркиНизьке
Найкраще дляУнікальний / живий контентМаркетинг, документаціяApp-подібні дашборди після логіну

Як це працює всередині

Запит потрапляє на Node.js-сервер. Next.js визначає сторінку як динамічну - через cache: 'no-store', виклик cookies(), headers() або export const dynamic = 'force-dynamic'. Запускається async server component, отримує дані через HTTP або напряму з БД. React перетворює дерево компонентів у HTML-рядок через ReactDOMServer.renderToString(). Цей HTML вирушає в браузер. Браузер завантажує JS-бандли, потім ReactDOM.hydrateRoot() підключає обробники подій до вже існуючого DOM без повторного рендеру.

Streaming змінює картину: з React 18 і App Router Next.js надсилає HTML-оболонку одразу, а чанки підтягуються по мірі того як кожна Suspense-межа резолвиться. Повільний API не гальмує всю сторінку.

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

Виклик cookies() у клієнтському компоненті

tsx
// Неправильно - викидає "Headers cannot be used in Client Components" 'use client'; import { cookies } from 'next/headers'; const session = cookies().get('session'); // помилка // Правильно - читай cookies у серверному компоненті, передавай через props async function ServerLayout() { const session = cookies().get('session'); return <ClientNav session={session?.value} />; }

next/headers працює лише на сервері. Клієнтські компоненти живуть у браузері, де заголовків запиту немає.

Забути cache: 'no-store' і отримати застарілі дані

tsx
// Неправильно - Next.js кешує цей запит за замовчуванням const data = await fetch('/api/live-prices'); // Правильно const data = await fetch('/api/live-prices', { cache: 'no-store' });

App Router кешує fetch за замовчуванням. Це заскакує більшість команд, що переходять з Pages Router де fetch за замовчуванням не кешувався. Без cache: 'no-store' сторінка повертає дані зі збірки, а не поточного запиту.

Блокувати всю сторінку на повільному API

tsx
// Неправильно - уся сторінка чекає Stripe перед відправкою HTML export default async function OrdersPage() { const res = await fetch('https://api.stripe.com/v1/orders', { cache: 'no-store' }); // користувач чекає 2+ секунди на перший байт } // Правильно - оболонка вирушає одразу, повільна частина стрімиться import { Suspense } from 'react'; export default function OrdersPage() { return ( <div> <h1>Ваші замовлення</h1> <Suspense fallback={<p>Завантаження...</p>}> <SlowOrders /> </Suspense> </div> ); }

Без Suspense-межі 2-секундна відповідь Stripe дорівнює 2-секундному TTFB і поганому Lighthouse score.

SSR на всіх маршрутах підряд

Кожен SSR-запит виконує серверний код. При великому трафіку це дорого і погано масштабується. Сторінки з однаковим контентом для всіх - блог, лендінг, документація - мають бути SSG. SSR тримай для сторінок, де контент справді різний для кожного користувача або кожного запиту.

Де зустрічається в реальних проектах

  • Vercel dashboard - дані білінгу зі Stripe API по сесії кожного юзера.
  • Netflix - персоналізований список перегляду, рендеринг на сервері з preferences кожного запиту.
  • GitHub - сторінки репозиторіїв з актуальним числом комітів.
  • IT Lead профіль - session cookie через cookies(), запит у БД за вирішеними задачами, рендер при кожному відкритті.

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

Q: Чим SSR відрізняється від ISR (incremental static regeneration)?
A: ISR будує HTML статично, але оновлює його у фоні за розкладом, наприклад revalidate: 60 означає не частіше разу на хвилину. SSR будує HTML заново на кожен запит. ISR дешевший, SSR завжди актуальний.

Q: Що відбувається при hydration mismatch?
A: React виводить попередження в консолі браузера і може перерендерити проблемну частину на клієнті. Зазвичай причина - різні дані на сервері та клієнті. Вирішення: переконайся, що обидві сторони використовують одне джерело даних.

Q: У чому різниця між React Server Components і SSR?
A: SSR - це стратегія рендерингу: генерувати HTML на сервері на кожен запит. React Server Components (RSC) - це модель компонентів: вони виконуються лише на сервері, отримують дані inline і не надсилають жодного JavaScript на клієнт. В Next.js App Router вони працюють разом, але це різні концепції.

Q: Повільний запит до БД додає 1.5 секунди до TTFB. Як вирішити?
A: Спочатку додай Suspense-межу навколо повільного компонента - оболонка вирушить одразу. Перевір наявність індексу на поле запиту і чи вибираєш тільки потрібні поля. Якщо контент не унікальний для кожного юзера - переведи компонент на ISR з коротким revalidate. Профілюй через Vercel Speed Insights або console.time() щоб точно знайти де витрачається час.

Приклади

Базовий: увімкнути SSR через cache: 'no-store'

tsx
// app/prices/page.tsx export default async function PricesPage() { const res = await fetch('https://api.example.com/prices', { cache: 'no-store' // Next.js не кешуватиме цю відповідь }); const prices = await res.json(); return ( <ul> {prices.map((item: { id: string; name: string; price: number }) => ( <li key={item.id}>{item.name}: {item.price} грн</li> ))} </ul> ); }

Прапор cache: 'no-store' - все що потрібно. Next.js бачить динамічний fetch і не включає маршрут у статичну збірку. Свіжі дані на кожен запит.

tsx
// app/profile/page.tsx import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; import { db } from '@/lib/db'; export default async function ProfilePage() { const cookieStore = cookies(); // автоматично позначає маршрут як динамічний const sessionId = cookieStore.get('session')?.value; if (!sessionId) redirect('/login'); const user = await db.user.findUnique({ where: { sessionId } }); return ( <div> <h1>Вітаємо, {user?.name}</h1> <p>Вирішено задач: {user?.solvedCount}</p> </div> ); }

Виклик cookies() з next/headers сигналізує Next.js що сторінка залежить від запиту. cache: 'no-store' тут не потрібен. Сторінка запитує БД при кожному відкритті, авторизація включена.

Продвинутий: streaming SSR з Suspense для повільного зовнішнього API

tsx
// app/orders/page.tsx import { Suspense } from 'react'; async function OrderList() { // Цей fetch блокує лише OrderList, не всю сторінку const res = await fetch('https://api.stripe.com/v1/charges', { cache: 'no-store' }); const { data: charges } = await res.json(); return ( <ul> {charges.map((c: { id: string; amount: number }) => ( <li key={c.id}>{(c.amount / 100).toFixed(2)} грн</li> ))} </ul> ); } export default function OrdersPage() { return ( <div> <h1>Ваші замовлення</h1> {/* Оболонка вирушає одразу. OrderList стрімиться після готовності. */} <Suspense fallback={<p>Завантаження замовлень...</p>}> <OrderList /> </Suspense> </div> ); }

<h1> і fallback-текст доходять до браузера ще до того як Stripe відповів. Коли OrderList резолвиться - React стрімить готовий HTML і замінює fallback. TTFB залишається низьким навіть з повільним API.

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

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

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

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