Як працює серверна рендеринг (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.
Швидкий приклад
// 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підходить краще.
Таблиця порівняння
| Характеристика | SSR | SSG | CSR |
|---|---|---|---|
| Дані отримуються | На кожен запит (сервер) | Під час збірки | Після завантаження (клієнт) |
| Початковий 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() у клієнтському компоненті
// Неправильно - викидає "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' і отримати застарілі дані
// Неправильно - 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
// Неправильно - уся сторінка чекає 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'
// 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 і не включає маршрут у статичну збірку. Свіжі дані на кожен запит.
Середній рівень: перевірка авторизації через session cookie (App Router)
// 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
// 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.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.