Стрімінговий SSR у Next.js 16: Suspense-межі, LCP та профілювання (2026)
Практичний гайд зі стрімінгового SSR у Next.js 16. Де саме розставити Suspense-межі, як не зруйнувати LCP, як профілювати у DevTools із React 19 Performance Tracks. З прикладами коду й чек-листом перед деплоєм.
Стрімінговий SSR у Next.js 16, це техніка серверного рендерингу, за якої браузер отримує HTML-каркас сторінки миттєво, а важкі динамічні секції (обгорнуті у <Suspense>) підвантажуються шматками через HTTP chunked transfer encoding, поки їхні дані розв'язуються на сервері. Так усувається класичний waterfall «чекаємо все або нічого», і довгий білий екран перетворюється на прогресивну появу контенту. Ключове питання не в тому, вмикати стрімінг чи ні (він увімкнений за замовчуванням у App Router), а в тому, де саме розставити Suspense-межі, щоб LCP не постраждав, а гідратація не заблокувала головний потік.
Стрімінг увімкнений за замовчуванням у App Router. Файл loading.tsx автоматично обгортає page.tsx у Suspense-межу зовнішнього рівня.
Розміщуйте <Suspense> максимально близько до асинхронного компонента: чим ближче до точки динамічного доступу, тим менша ділянка «падає» у fallback.
LCP-елемент має залишатися поза межами Suspense, інакше він потрапить у другий чанк, і показник погіршиться на 400–1200 мс.
Skeleton-фолбеки повинні збігатися за розмірами з реальним контентом, інакше CLS зростає при заміні.
Кожна Suspense-межа, це окрема одиниця гідратації: замість однієї блокуючої гідратації сторінки React виконує кілька коротких, які поступаються браузеру.
Профілюйте у Chrome DevTools Performance + React Performance Tracks: підтрек Suspense показує, скільки часу межа була у fallback-стані.
Що таке стрімінговий SSR у Next.js 16
Класичний SSR працює за принципом «все або нічого». Сервер збирає всю сторінку, чекає на найповільніший fetch і лише потім віддає HTML одним великим шматком. Стрімінговий SSR розбиває цю відповідь на послідовність чанків. Спочатку летить каркас (layout, шапка, LCP-елемент), далі йде HTML для кожної Suspense-межі в міру того, як розв'язуються її дані. React 19 і Next.js 16 роблять це поверх HTTP chunked transfer encoding, тому браузер починає рендерити перший байт (TTFB → FCP) на сотні мілісекунд раніше.
У моїх профілях великих SaaS-дашбордів це виглядає так. Без стрімінгу FCP тримається на 1.8–2.4 с, тому що сервер чекає на найповільнішу з чотирьох баз даних. Із трьома правильно розміщеними Suspense-межами той самий маршрут дає FCP 340 мс, а весь контент прибуває приблизно за той самий час, що й раніше. Тобто стрімінг не робить сторінку швидшою за секундоміром, він робить її сприйняття швидшим. У цьому й полягає різниця між технічною й перцептивною продуктивністю, і саме її люблять Core Web Vitals.
У Next.js 16.2 стабілізувалися use cache, cacheLife, cacheTag, updateTag, а Turbopack став дефолтним бандлером. Це прямо впливає на стрімінг, бо кешовані сегменти віддаються з першого чанка, залишаючи Suspense лише для справді динамічних даних. Якщо ви ще не перейшли на нову модель кешування, почніть з нашого повного посібника з директиви use cache. Він добре стикується з тим, про що йдеться нижче.
Коли використовувати loading.tsx, а коли Suspense
Це найчастіше запитання, яке я чую на код-рев'ю. Обидва інструменти створюють Suspense-межу, але працюють на різних рівнях гранулярності.
loading.tsx: межа рівня маршруту
Файл loading.tsx автоматично обгортає весь сегмент маршруту (тобто page.tsx і всі вкладені page.tsx) у одну зовнішню Suspense-межу. Layout залишається інтерактивним, тобто користувач може клікати навігацію, поки сторінка перемальовується. Це ідеальний варіант, коли вся сторінка залежить від одного головного асинхронного джерела.
Явна <Suspense> дозволяє стрімити кожну асинхронну секцію окремо. Дві сусідні межі розв'язуються незалежно: якщо <Stats> відповідає за 200 мс, а <RecentOrders> за 1200 мс, користувач побачить статистику через 200 мс і замовлення через 1200 мс, без блокування.
Правильне розміщення Suspense-меж: практичні патерни
Правило, яке я повторюю на кожному воркшопі: Suspense-межа має бути якомога ближче до асинхронного доступу. Коли prerenderer Next.js 16 натрапляє на динамічну операцію (await fetch(), await cookies(), await headers()), він шукає найближчу межу вгору по дереву. Якщо межі немає, білд падає з помилкою blocking route. Якщо межа є на самому верху сторінки, весь маршрут падає у fallback замість гранулярного стрімінгу.
Патерн 1: Не переносіть fetch на самий верх
// Погано: fetch тут блокує весь маршрут
export default async function Page() {
const stats = await getStats(); // 800 мс
const orders = await getOrders(); // 1200 мс
return <Dashboard stats={stats} orders={orders} />;
}
// Добре: кожен fetch у своєму серверному компоненті
export default function Page() {
return (
<>
<Suspense fallback={<StatsSkeleton />}><StatsAsync /></Suspense>
<Suspense fallback={<OrdersSkeleton />}><OrdersAsync /></Suspense>
</>
);
}
Патерн 2: Паралелізуйте, не серіалізуйте
Класична пастка waterfall, це await у батька, який тільки потім рендерить дітей. У DevTools це видно як послідовні смуги на треку Server Requests. Виносьте await у листки дерева, Next.js виконає їх паралельно, тому що кожен обгортається у власну Suspense-межу.
Патерн 3: Не обгортайте компонент у Suspense «зсередини»
Найпоширеніша помилка новачків, це покласти <Suspense> всередину компонента, який чекає дані. Так не працює: React призупиняє рендер того самого компонента, який містить межу. Межа має бути в батька.
LCP і CLS: як стрімінг впливає на Core Web Vitals
Стрімінг, це не «безкоштовна» перемога. Погано розміщені межі можуть погіршити LCP і CLS. Ось два залізобетонні правила з мого досвіду профілювання продакшен-сторінок.
LCP-елемент завжди у першому чанку
Найбільший видимий елемент (герой-зображення, головний заголовок, ключова картка) не має бути під Suspense-межею. Інакше він потрапляє у другий (або третій) чанк, і LCP стрибає з 800 мс до 1.6–2.4 с. Для героїчного зображення додавайте priority прапорець на next/image. Це вставить <link rel="preload"> у <head> першого чанка. Я особисто напоровся на цю пастку минулого місяця під час аудиту маркетингової сторінки: обгорнули «розумно» весь hero у Suspense, і LCP стрибнув з 900 мс до 2.1 с. Прибрали межу, і показник повернувся.
// app/page.tsx — LCP залишається у статичному каркасі
import Image from 'next/image';
import { Suspense } from 'react';
export default function Home() {
return (
<>
<Image
src="/hero.webp"
alt="Hero"
width={1200}
height={630}
priority
/>
<h1>Швидкі маршрути з App Router</h1>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</>
);
}
Skeleton має відповідати розмірам справжнього контенту
Коли fallback замінюється контентом, браузер робить reflow. Якщо skeleton нижчий за реальну картку, сусідні блоки стрибають, і CLS вилітає за 0.1. Фіксуйте min-height на контейнері Suspense або малюйте skeleton із точними розмірами.
Профілювання стрімінгу у DevTools: до і після
Я живу у Chrome DevTools і React DevTools. Ось конкретний workflow, який завжди дає відповідь на питання «а стрімінг взагалі працює?».
Крок 1: Мережевий waterfall
Відкрийте вкладку Network, вимкніть кеш, увімкніть троттлінг Slow 4G і перезавантажте маршрут. Клацніть на HTML-документі і подивіться на Timing. У класичному SSR ви побачите один великий блок «Content Download». У стрімінговому побачите послідовність шматочків із зростаючим Content-Length і Transfer-Encoding: chunked.
Крок 2: Performance-запис
Робіть 6-секундне трасування у вкладці Performance. Шукайте на треку Frames моменти, коли з'являються нові шматки DOM. Якщо ви бачите один великий стрибок layout після довгої паузи, стрімінг не працює, або всі межі впакували в одну зовнішню.
Крок 3: React Performance Tracks
У React 19 з'явилися нативні треки продуктивності просто у Chrome DevTools. Scheduler-трек розділений на підтреки Blocking, Transition, Suspense та Idle. Підтрек Suspense, ваш золотий стандарт: він показує проміжок часу, коли межа перебувала у fallback-стані. Якщо смужка на цьому треку триває 1200 мс, саме стільки користувач бачив skeleton. Якщо ви бачите десятки коротких смужок, межі занадто гранулярні, це «popcorn effect».
Крок 4: React DevTools Profiler
Окрема панель React DevTools дозволяє переключити прапорець «Suspend» для будь-якої межі і зафіксувати fallback у DOM. Зручно, щоб довести стилі skeleton. Крім того, Profiler показує, які компоненти рендеряться найдовше, це кандидати номер один на власну Suspense-межу.
Чому сторінка все ще відчувається повільною зі стрімінгом
Кілька разів на місяць мене питають: «Ми обгорнули все у Suspense, а сторінка як і раніше повільна». Ось топ-4 причини, які я реально бачив у профілях.
1. Waterfall всередині серверного компонента
Стрімінг не рятує від послідовних await. Якщо у компоненті виконуються три fetch-и підряд, вони й далі йдуть серіалізовано. Використовуйте Promise.all(), або, ще краще, розділіть на три компоненти під окремими межами.
2. Занадто велика зовнішня межа
Якщо loading.tsx зверху на сегменті проковтує все, окремі внутрішні <Suspense> не мають шансу. Prerenderer знаходить найближчу межу і зупиняється. Приберіть loading.tsx або зробіть його дуже легким (наприклад, тільки шапка).
3. Кожна межа тягне свій запит на аутентифікацію
Класична пастка: кожен серверний компонент викликає getSession(), який робить окремий запит до БД. Оберніть getSession у React cache(), і всі виклики впродовж одного рендер-пасу поділять один результат. Для інтеграції з новим proxy.ts подивіться наш матеріал про міграцію з middleware.ts на proxy.ts.
4. Клієнтські бандли блокують гідратацію
Стрімінг доставляє HTML швидко, але якщо клієнтський JS у 850 КБ, TTI все одно буде поганим. Виміряйте бандл у Turbopack-звіті і використайте селективну гідратацію (Suspense поділяє гідратацію на дрібніші задачі, які поступаються браузеру). Оптимізація бандлів у Turbopack, це окрема велика тема. Я розписав її у гіді з Turbopack продакшен-білдів.
Обробка помилок під час стрімінгу
Стрімінг починається до того, як розв'язалися всі дані, тому традиційний try/catch на рівні сторінки більше не працює для помилок у нижніх межах. Кожну <Suspense> варто парити з error.tsx (для маршруту) або <ErrorBoundary> (для компонента). Якщо секція падає після початку стрімінгу, найближчий error boundary замінить її на fallback, а решта сторінки залишиться цілою.
Мережевий waterfall показує кілька чанків HTML із chunked-кодуванням.
LCP-елемент присутній у першому чанку (перевіряється у Performance-записі).
Кожна Suspense-межа має відповідний за розмірами skeleton, CLS < 0.1.
Немає одного зовнішнього loading.tsx, що ковтає всі внутрішні межі.
Аутентифікаційні виклики загорнуті у cache().
Кожна межа парується з error.tsx або <ErrorBoundary>.
Підтрек Suspense у React Performance Tracks не показує «popcorn», межі логічно згруповані.
Кешовані сегменти через use cache та Partial Prerendering виносять статику в перший чанк.
Пройшли всі 8 пунктів, можна деплоїти. Чесно, приблизно 70% моїх аудитів провалюються на пунктах 2, 4 або 5, і кожен з них дає -300...-800 мс до LCP після виправлення.
Часті запитання
Чи потрібно вручну вмикати стрімінг у Next.js 16?
Ні. У App Router стрімінг увімкнений за замовчуванням. Достатньо додати loading.tsx або обгорнути асинхронні серверні компоненти в <Suspense>. Pages Router (застарілий у 16-й версії) стрімінгу не має.
Suspense працює з клієнтськими компонентами?
Так, але з іншим сценарієм: клієнтський компонент має «викидати» проміс через use() або читати дані з JS-фреймворка, який підтримує Suspense (React Query, SWR, Relay). Серверний стрімінг Next.js 16 працює лише з асинхронними серверними компонентами.
Чи не збільшує стрімінг час до повного інтерактиву (TTI)?
Ні, зазвичай зменшує. Кожна Suspense-межа стає окремою одиницею селективної гідратації: React гідрує ті ділянки, з якими користувач взаємодіє першими, а решту у фоні. Головний потік менше блокується, і TTI поліпшується на 15–30%.
Як debug'ити hydration errors, які з'являються тільки під час стрімінгу?
Найчастіша причина, це недетерміністичний вивід (Date.now(), Math.random(), локальний час) у серверному компоненті. Використайте useId() для стабільних ідентифікаторів і suppressHydrationWarning лише для справді ізольованих випадків. Chrome DevTools у React 19 підсвічує різницю між серверним і клієнтським DOM просто у консолі.
Чи можна стрімити Route Handlers і API routes?
Так, через ReadableStream у відповіді Route Handler. Але Suspense-механіка стосується лише рендерингу RSC. Для API-ендпоінтів використовуйте вручну new Response(readableStream) з Transfer-Encoding: chunked. Це інший інструмент для інших задач.
Turbopack у Next.js 16 — стабільний Rust-білдер, що замінив Webpack. Як увімкнути, налаштувати loaders і aliases, використати persistent cache та мігрувати без даунтайму.
Як працює експериментальний прапор dynamicIO у Next.js 16, як вибрати між use cache та await connection(), та як уникнути типових помилок збірки під час міграції.
Як використовувати after() у Next.js 16 для логування, аналітики та фонових задач після відповіді. Робочі приклади для Server Components, Route Handlers, Server Actions, proxy.ts, порівняння з waitUntil() і налаштування self-hosted розгортання.