Senior Frontend Architect, 10+ років досвіду побудови production-проєктів на Next.js. Contentful Certified Professional (2024). Спеціалізація: React Server Components, headless eCommerce, інженерія Core Web Vitals.
Partial Prerendering (PPR) у Next.js, експериментальний з версії 14 і поступово доведений до продакшену в 15, розв'язує фундаментальний компроміс рендерингу, який стримував React-архітектури з 2016 року: або швидкий TTFB зі статичною генерацією і без серверної персоналізації, або коректна серверна персоналізація з повільним TTFB. Підхід PPR - зібрати статичну HTML-оболонку на збірці й стримити динаміку в межі Suspense на запиті - не підкрутка жодної з моделей. Це інша модель рендерингу, що поєднує дві речі, які раніше разом не працювали: конкурентний рендерер React зі стрімінгом через Suspense і здатність Next.js розділити один маршрут на статичний артефакт збірки й потоковий рендерер часу запиту. У цьому пості я розбираю, як влаштована двофазна відповідь, як правильно розставляти межі Suspense, як PPR взаємодіє з Full Route Cache і які цифри продуктивності це дає в продакшені.
Частина I - Компроміс рендерингу, який розв'язує PPR
Найпростіше зрозуміти PPR на сторінці, де статика й динаміка перемішані. Візьмімо картку товару: назва, опис і картинки статичні для SKU і кешуються на години; ціна залежить від географії й того, чи залогінений користувач, і кешуватися не може; бейдж наявності мусить бути свіжим; блок «Ви нещодавно дивилися» персональний. Кожна попередня стратегія рендерингу на такій сторінці ламається по-своєму.
- ISR (уся сторінка статична): Сторінка пререндериться на збірці із зафіксованими ціною і наявністю. CDN віддає її з TTFB менш як 50 мс. Але ціна може виявитися хибною для регіону користувача, а наявність застарілою. Щоб показати правильні значення, застосунок змушений додати клієнтські запити (
useEffect → fetch → setState) і повернути той самий водоспад, який RSC мав прибрати. Сторінка малюється зі скелетонами, поки виконується JavaScript, і LCP деградує. - SSR (уся сторінка динамічна): Читає
cookies()для визначення регіону, ходить в API цін, перевіряє наявність - усе на запиті. Значення правильні. Але TTFB тепер 300–800 мс, тобто час розв'язання всіх залежностей, замість колишніх 50. Цей TTFB напряму затримує LCP: браузер не може відмалювати найбільший елемент, доки не прийшов перший байт. - Стрімінговий SSR (без PPR): Конкурентний рендерер React уміє стримити шматки HTML у міру готовності даних. Але без PPR весь маршрут лишається динамічним: перший байт - це результат роботи стрімінгового рендерера на запиті, а не закешована відповідь CDN. Оболонка обчислюється щоразу заново. Стрімінговий SSR покращує TTFB відносно блокувального SSR, надсилаючи оболонку до завершення запитів до бази, але сама оболонка все одно генерується на кожен запит, а не віддається з кешу.
- PPR (статична оболонка плюс динамічні діри): Статичний вміст товару - назва, опис, картинки - пререндериться на збірці й кешується на краю CDN. На запиті CDN миттєво повертає закешовану оболонку (TTFB менш як 50 мс), а потім відкриває потокове з'єднання з динамічним рендерером, який заповнює межі Suspense ціни, наявності та персоналізації в міру готовності даних. Елемент LCP - hero-картинка товару - лежить у статичній оболонці й малюється зі швидкістю CDN, а не зі швидкістю динамічного рендерингу.
Частина II - Механізм двофазної відповіді
PPR розділяє рендеринг одного маршруту на дві фази, які виконуються в різних обчислювальних контекстах: фаза збірки виробляє статичну HTML-оболонку, фаза запиту стримить у неї динаміку.
На збірці Next.js рендерить маршрут з усіма динамічними контекстами призупиненими: виклики cookies(), headers() і звернення до searchParams усередині меж Suspense змушують ці межі відмалювати свій фолбек. Результат - цілий HTML-документ, де динамічні області представлені своїми заглушками: скелетонами, індикаторами завантаження або порожніми плейсхолдерами. Цей HTML кладеться в краєвий кеш CDN так само, як статично згенерована сторінка.
На запиті CDN миттєво повертає закешовану оболонку. HTTP-з'єднання лишається відкритим (chunked transfer encoding у HTTP/1.1 або мультиплексовані потоки HTTP/2 і HTTP/3), а запит паралельно йде динамічному рендереру - серверлес-функції на origin. Динамічний рендерер виконує обгорнуті в Suspense компоненти зі справжнім контекстом запиту: cookies() читає реальні куки, headers() - реальні заголовки. У міру розв'язання даних кожної межі рендерер стримить тег <script> із навантаженням серверних компонентів, який велить рантайму React у браузері замінити заглушку справжнім вмістом. Це узгодження DOM, а не повний перерендер, тому зсуву лейауту не буде, якщо в заглушки правильні розміри.
- Механіка chunked transfer encoding: Chunked transfer encoding у HTTP (RFC 7230) дозволяє серверу віддавати відповідь частинами, не знаючи наперед загальної довжини. CDN надсилає статичну оболонку першим шматком негайно. Динамічний рендерер дописує наступні шматки - теги
<script>для кожної розв'язаної межі Suspense - у ту саму HTTP-відповідь у міру готовності. Браузер обробляє кожен шматок при надходженні: перший - це повністю розбірний HTML, який можна відмалювати й показати, не чекаючи на решту. - Артефакт статичної оболонки: Згенерована на збірці оболонка містить цілий HTML-документ:
<html>,<head>з усіма метаданими й підказками<link rel='preload'>, повне статичне дерево RSC і HTML заглушок для кожної динамічної межі. Отже, браузер отримує підказку передзавантаження LCP-картинки вже в першому шматку і починає її тягнути, поки динамічний рендерер ще рахує ціну й наявність. - Ізоляція динамічного рендерера: Динамічна частина PPR-маршруту розгортається як окремий виклик серверлес-функції. У неї є повний контекст запиту - куки, заголовки, параметри, - але виконує вона лише піддерева всередині Suspense. Архітектурно це не те саме, що повний SSR-рендер: динамічний рендерер робить менше роботи на запит, що знижує і вплив холодного старту, і вартість виконання.
Частина III - Архітектура меж Suspense: правила статики й динаміки
Межа Suspense - основна одиниця розбиття в PPR. Усе, що поза межею, статичне; усе, що всередині, динамічне. Архітектурна дисципліна тут у тому, щоб ставити межі точно й обгортати мінімальну динамічну поверхню, а не максимальну.
- Що робить рендер динамічним (мусить бути всередині Suspense, інакше PPR не працює): читання
cookies()іheaders(), доступ доsearchParamsіз пропсів сторінки, викликиMath.random(), викликиnew Date()без фіксованого значення,fetch()ізcache: 'no-store'абоnext: { revalidate: 0 }, а також будь-який запит даних, якому потрібен контекст конкретного запиту: ідентифікатор користувача із сесії, локаль із куки, варіант A/B-тесту з куки. - Що безпечно статичне (мусить бути ЗЗОВНІ Suspense): назва товару, опис, картинки й структура їхніх URL, статична навігація, футер, розмітка schema.org, хлібні крихти (коли вони не залежать від сесії), метадані сторінки, передзавантаження шрифтів і будь-які дані, однакові для всіх користувачів і кешовні з розумним TTL.
- Антипатерн розміщення меж: Обгорнути весь
<main>в одну межу Suspense - означає вбити PPR: увесь видимий вміст стає динамічною дірою, а в статичній оболонці лишаються тільки<html>,<head>,<nav>і<footer>. Функціонально це той самий повний SSR зі скелетоном. Правильна гранулярність - одна межа на незалежну динамічну задачу: ціна, наявність, персональні рекомендації, стан кошика. Незалежні межі стримляться паралельно і розв'язуються незалежно: повільний запит рекомендацій не затримує відмальовування ціни. searchParams- найчастіша пастка PPR: У Next.jssearchParamsдинамічний за визначенням: на збірці його знати не можна. Компонент сторінки, який деструктуруєsearchParamsіз пропсів, переводить усю сторінку в динамічний рендер і обходить PPR цілком. Лікування - потягти роботу ізsearchParamsу дочірній компонент усередині Suspense:<Suspense fallback={<SortFallback />}><SortedProductList searchParams={searchParams} /></Suspense>. Статична сітка товарів тоді прийде з CDN, а динамічний запит спричинить лише відсортований чи відфільтрований варіант.
Частина IV - Налаштування PPR у Next.js 15
- Інкрементальний режим (рекомендований для продакшену): У
next.config.ts:experimental: { ppr: 'incremental' }. Увімкнення за маршрутами:export const experimental_ppr = true;згори будь-якогоpage.tsxчиlayout.tsx. Так PPR вмикається маршрут за маршрутом, а не глобально, і викочувати його в прод можна безпечно, спостерігаючи за кожним кроком. - Глобальний режим:
experimental: { ppr: true }вмикає PPR для всіх маршрутів застосунку. Це шлях до повного переходу, але він вимагає, щоб розстановку меж Suspense було перевірено й підтверджено на кожному маршруті. - Сумісність із
generateStaticParams: PPR працює з ним коректно. Статична оболонка для кожного пререндереного сполучення параметрів лежить у кеші CDN. Динамічні діри розв'язуються на кожен запит зі справжнім контекстом. ISR (revalidate = N) оновлює оболонку незалежно від дір: при перегенерації кешується новий статичний вміст, а динаміка завжди рахується заново. - Поведінка при деплої на Vercel: На Vercel PPR-маршрут дає два артефакти розгортання: закешований на CDN статичний ассет (оболонка) і Edge- чи серверлес-функцію (динамічний рендерер). Ассет віддається глобально з краєвих вузлів, а рендерер виконується в регіоні, найближчому до користувача. Це розділення видно в аналітиці Vercel: запити статичної оболонки не потрапляють у виклики функцій.
Частина V - Продакшен-сценарії, де PPR дає максимум
Користь PPR пропорційна частці статики у вмісті вище згину. Ідеальний кандидат - сторінка, де елемент LCP і більшість видимого статичні, а осмислена динаміка (персоналізація, стан сесії, дані реального часу) живе поруч або нижче.
- Картка товару (максимальний ефект): Статично: назва, hero-картинка, опис, характеристики, хлібні крихти, розмітка schema.org. Динамічно й обгорнуто в Suspense: ціна (залежить від географії), бейдж наявності, стан кнопки «У кошик», рекомендації, зведення відгуків. Hero-картинка товару, зазвичай і є елемент LCP на мобільних, лежить у статичній оболонці й вантажиться з кешу CDN. Типове покращення LCP на картці: з 1,8–2,4 с (SSR із походами в API) до 0,6–1,0 с. Як PPR вбудовується в ширшу архітектуру headless-комерції, розібрано в гайді зі збірки headless Shopify.
- Маркетингові сторінки з A/B-тестами: Статично: увесь маркетинговий текст, hero-картинки, соціальні докази. Динамічно в Suspense: призначення варіанта A/B (читається з куки експерименту), персоналізований текст CTA. Без PPR будь-яке A/B-тестування через куки заганяє всю сторінку в SSR. З PPR перший екран і текст приходять із CDN, а динамічними дірами лишаються тільки чутливі до експерименту кнопки.
- Дашборди за авторизацією: Статично: структура навігації, бічне меню, скелет сторінки, неперсональні віджети на кшталт привітання за часом доби без імені. Динамічно: ім'я користувача, баланс рахунку, сповіщення, стрічка активності. Навігація, де на десктопі зазвичай і міститься елемент LCP (логотип або банер), вантажиться зі швидкістю CDN незалежно від стану авторизації.
- Блоги й контентні сайти з читацькими функціями: Статично: сам матеріал - заголовок, текст, картинки, розмітка, тобто переважна більшість сторінки. Динамічно: індикатор авторизації, статус закладки, варіант пропозиції підписки. Пост, де 95% сторінки - це стаття, оптимізується під PPR практично безкоштовно: статична оболонка і є майже вся сторінка.
Частина VI - Проєктування заглушок Suspense: нульовий CLS
Cumulative Layout Shift - метрика Core Web Vitals, яка при переході на PPR ризикує найсильніше. Коли заглушку Suspense змінює справжній вміст, а розміри в них різні, лейаут стрибає - і це подія CLS. Поганий CLS від PPR-заглушок гірший за CLS від повного SSR-рендеру, бо користувач бачить стрибок уже після того, як сторінка відмалювалася.
- Розміри скелетонів: У компонентів-заглушок мають бути явні фіксовані розміри, що збігаються з розмірами справжнього вмісту. Заглушка ціни на кшталт
<div className="h-7 w-20 animate-pulse bg-gray-200 rounded" />підходить, якщо справжня ціна завжди займає приблизно 28 на 80 пікселів. Якщо ціна може стати вищою - наприклад, в один рядок або у два з промо-бейджем, - заглушка зобов'язана закладати максимальну висоту, інакше вміст поїде вниз. min-heightна динамічних областях: Для динаміки змінної висоти - каруселі рекомендацій, зведення відгуків - задайте обгортці Suspensemin-heightпід мінімально очікувану висоту вмісту. Це не дає сторінці стиснутися в стані заглушки й розтягнутися, коли прийдуть справжні дані.- Loading.tsx як потокова заглушка всієї сторінки: Файл
loading.tsxв App Router створює межу Suspense навколо всього сегмента сторінки. Він доречний для переходів між маршрутами, але не має бути єдиною межею в PPR-маршруті: тоді вся сторінка стане динамічною. Використовуйтеloading.tsxдля станів навігації, а для розбиття на PPR-межі - дрібні вбудовані Suspense. - Підказки передзавантаження в статичній оболонці: Оскільки оболонка приходить першим HTML, будь-які теги
<link rel='preload'>у її<head>спрацьовують негайно. Для LCP-картинки це означає<link rel='preload' as='image' href='/product-hero.avif' />у<head>, який генерує компонентnext/image: завантаження зображення стартує, поки браузер ще приймає статичний HTML. Поєднання швидкого TTFB від CDN і ранньої передзавантаження картинки й дає те покращення LCP, заради якого PPR затівався.
Частина VII - PPR і Full Route Cache
Щоб передбачати виграш у TTFB на проді, треба розуміти, як PPR взаємодіє з Full Route Cache. Цей кеш зберігає відмальований вивід маршруту, щоб не рендерити на кожен запит. Без PPR будь-який маршрут, який читає cookies() або headers(), із кешу випадає і мусить рендеритися на сервері щоразу. Саме так будь-яка персоналізація чи перевірка авторизації заганяє сторінку в SSR.
- PPR повертає Full Route Cache персоналізованим маршрутам: Коли
cookies()викликається всередині межі Suspense, тобто в динамічній дірі PPR, Next.js знає, що динамічне лише це піддерево, а решта маршруту кешовна. Статична оболонка потрапляє у Full Route Cache і в кеш CDN незалежно від того, що відбувається всередині меж. Викликcookies()більше не отруює кешовність усього маршруту. - Інвалідація оболонки й динаміки незалежна: Оболонка інвалідується ревалідацією ISR - за часом або на вимогу через
revalidateTag. Динаміка не кешується взагалі й рахується заново на кожен запит. Сторінка товару зrevalidate = 3600перегенерує оболонку раз на годину, а ціна й наявність у дірах Suspense завжди відображають поточний стан. Такий поділ статичної свіжості (раз на годину) і динамічної (на кожен запит) архітектурно чистіший за будь-які альтернативи. - Край CDN проти виконання на origin: Статична оболонка віддається прямо з краєвого вузла CDN по всьому світу, TTFB приблизно 10–50 мс залежно від географічної близькості. Динамічні діри виконуються на origin-сервері або в Edge-функції Vercel для глобального розподілу. Сприйнятий користувачем TTFB - це час відповіді CDN: затримка динамічного рендерингу схована за потоковим з'єднанням, яке вже відкрилося, коли CDN віддав перший байт.
Частина VIII - Виміряні результати
Виграш від PPR лежить насамперед у TTFB і LCP. Профіль нижче репрезентативний для продакшен-розгортань на Vercel з отриманням даних через Storefront API Shopify (затримка API до origin 50–200 мс).
- TTFB: Повний SSR із походами в API: 300–800 мс (p75). PPR із закешованою оболонкою: 20–80 мс (p75). Покращення в 4–10 разів. Воно не залежить від затримки вищого API, бо оболонка повертається до того, як зроблено бодай один динамічний виклик.
- LCP: На картці, де hero-картинка лежить у статичній оболонці, повний SSR дає LCP 1,8–2,4 с на мобільних p75, а PPR - 0,6–1,2 с. Скорочення складається з двох ефектів: TTFB нижчий, тобто HTML приходить раніше, і
<link rel='preload'>спрацьовує раніше, тобто завантаження картинки стартує з першим байтом HTML. Механіка LCP і методика вимірювання розібрані в архітектурі вебпродуктивності. - INP: Прямого впливу на INP у PPR немає. INP міряє чутливість взаємодій, тобто затримку обробника, а не завантаження сторінки. Непряма користь є: менші клієнтські бандли (RSC рендерить більше на сервері) скорочують час розбору JS, а це може знизити INP на взаємодіях під час завантаження. Але це заслуга RSC, а не самого PPR. Методика вимірювання і повний план зниження - в оптимізації INP у Next.js 16.
- CLS: Підсумок цілком залежить від того, як спроєктовані заглушки. Добрі скелетони з явними розмірами дають CLS 0, тобто зсуву немає. Погані - наприклад,
display: none, який змінюється блоком у 200 пікселів, - дають CLS більше 0,1. PPR додає ризик CLS, якого за повного SSR немає: за це доводиться платити свідомим проєктуванням заглушок.
Частина IX - Фреймворк рішення: PPR чи альтернативи
- Беріть PPR, коли: (1) На сторінці є ясна статична область вище згину, і в ній лежить елемент LCP. (2) Динаміка обмежена конкретними впізнаваними областями - ціна, стан сесії, рекомендації, - які можна ізолювати в межі Suspense. (3) Поточна архітектура - повний SSR із TTFB вище 300 мс через вищі залежності. (4) Ви на Vercel або іншій платформі, яка підтримує зв'язку статичної оболонки й стрімінгу. (5) Core Web Vitals показують LCP вище 2,5 с на мобільних p75, і головний внесок робить саме TTFB.
- Беріть ISR плюс клієнтський запит (без PPR), коли: (1) Динаміка лежить нижче згину і на LCP не впливає. (2) Динаміка рідкісна або необов'язкова, як бейдж сповіщень, і клієнтський водоспад прийнятний. (3) Команда ще не опанувала дисципліну меж Suspense, і ризик отримати CLS через помилки в заглушках високий.
- Беріть повний SSR (без PPR), коли: (1) Усе вище згину динамічне й персональне, статичної оболонки просто немає. (2) Сторінці потрібні ціни аукціону чи ставок у реальному часі, синхронні по всій HTML-відповіді. (3) Застосунок розгорнуто на платформі, яка зв'язку CDN і стрімінгу не підтримує.
- PPR - неправильний вибір, коли:
searchParamsвизначає весь лейаут сторінки, наприклад на сторінці результатів пошуку, де позиція, сортування і фільтри впливають на все вище згину. Тоді статична оболонка виродиться в скелетон без осмисленого вмісту, і виграш PPR зникне. Для сторінок пошуку зазвичай краще ISR із клієнтською фільтрацією або повний SSR з агресивним краєвим кешуванням.
Частина X - Відомі обмеження і продакшен-застереження
- Поширення
searchParams: Як сказано в третій частині, будь-який компонент у дереві статичної оболонки, який чіпаєsearchParams, переводить усю сторінку в динамічний рендер. Це типовий біль міграції: перед увімкненням PPR на маршруті проженіть пошук за використаннямsearchParamsу дереві компонентів і заведіть усі звернення всередину Suspense. - Middleware і правка заголовків відповіді: Middleware, який змінює заголовки відповіді - наприклад, ставить
Set-CookieчиVaryзалежно від вмісту запиту, - може несподівано взаємодіяти із закешованою статичною оболонкою: вона закешована без знання про ці правки. Перевіряйте зв'язку middleware і PPR явно до викату в прод. - Холодний старт динамічного рендерера: На серверлес-платформах у функції динамічного рендерера є штраф холодного старту, 50–500 мс на першому виклику після простою. Це впливає на затримку розв'язання динамічних дір, але не на TTFB статичної оболонки. У маршрутів із постійним трафіком холодні старти рідкісні; у малопопулярних вони здатні зробити динамічні діри повільнішими за очікування.
- PPR у Next.js 15 формально
experimental(інкрементальний режим стабільний для проду): Прапорецьexperimental_ppr = trueна маршруті задокументований як готовий до продакшену в інкрементальному режимі. Глобальне налаштуванняppr: trueусе ще вважається експериментальним. Стежте за changelog Next.js: переведення у стабільні очікується в Next.js 16. - Межі помилок у динамічних дірах: Якщо запит даних у динамічній дірі падає - таймаут API, відповідь 500, - межа Suspense відкочується до свого Error Boundary з
error.tsx. Статична оболонка при цьому лишається цілою і відмальованою. Користувач бачить сторінку з деградацією: статика на місці, а в динамічних дірах стан помилки, - замість повносторінкової помилки. Архітектурно це краще за SSR, де один упалий вищий виклик дає 500 на всю сторінку.
Висновок
PPR - найбільша зміна архітектури рендерингу в Next.js з часів RSC. Він лагодить структурну проблему, яку ISR, SSR і стрімінговий SSR лишали відкритою: не можна було віддавати закешований на CDN HTML із TTFB менш як 50 мс на маршруті, де є серверна динаміка. Підхід через межі Suspense - оболонка на збірці, заповнення стрімінгом на запиті - чисто лягає на решту моделі App Router.
На практиці він вимагає дисципліни меж: у кожної динамічної задачі має бути своя межа Suspense, а в кожної заглушки - правильні розміри, щоб не породити CLS. Команди, які вкладаються в цю інженерію меж, бачать покращення LCP у польових даних CrUX уже за кілька днів після викату. Як PPR вбудовується в модель рендерингу App Router цілком, розібрано в гайді з міграції на App Router.
Рев'ю архітектури PPR, впровадження або інженерія Core Web Vitals - послуга оптимізації продуктивності | кейси | обговорити проєкт.
Часті питання
- Чи доступний PPR у Next.js 14? PPR з'явився в Next.js 14 як експериментальний під прапорцем
experimental: { ppr: true }. Інкрементальний режим ('incremental'), який дозволяє вмикати його за маршрутами, додали в Next.js 15. Для продакшену рекомендований шлях - інкрементальний режим у 15-й версії. - Чи працює PPR із
getServerSideProps? Ні. PPR - можливість лише App Router, аgetServerSidePropsналежить до Pages Router. Якщо ви на Pages Router і вам потрібні можливості PPR, спершу переїжджайте на App Router - дивіться гайд із міграції. - Чи можна використовувати PPR із багатомовністю? Так. Статична оболонка генерується для кожної локалі, якщо у вас маршрути з локаллю у шляху.
app/[locale]/product/[handle]/page.tsxдає по оболонці на кожне сполучення локалі й хендла товару. Динамічні діри - ціна, стан сесії - розв'язуються на кожен запит незалежно від локалі. - Як PPR впливає на Time to Interactive? Опосередковано покращує: менші клієнтські бандли (у статики, відмальованої через RSC, клієнтського JS немає) скорочують час розбору. Основний внесок PPR - у TTFB і LCP, а не напряму в TTI. Якщо у вас високий TTI, корінь проблеми радше в надлишку клієнтського JavaScript - дивіться розбір меж
'use client'у гайді з міграції на App Router. - Що буде, якщо динамічний рендерер повільний? Користувач бачить заглушки Suspense увесь час, поки триває динамічний рендер. Вміст статичної оболонки при цьому повністю видно і він працює: навігація клікається, статичний текст читається. Це принципово краще за повний SSR, де вся сторінка чекає на найповільнішу вищу залежність.
Джерела
- Next.js Team. «Partial Prerendering»
- Vercel. «Partial Prerendering with Next.js»
- Next.js Team. «Next.js 15 Release Notes»
- React Team. «Suspense Reference»
- RFC 7230. «Hypertext Transfer Protocol - Chunked Transfer Encoding»
- Google. «Largest Contentful Paint»
- Google. «Cumulative Layout Shift»
- Google. «Time to First Byte»
- Next.js Team. «Full Route Cache»
- HTTP Archive. «Web Almanac 2025 - Performance»
