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-заглушек хуже, чем от полного 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»
