Next.js Partial Prerendering (PPR), экспериментальный с Next.js 14 и инкрементально production-стабильный в Next.js 15, решает фундаментальный рендеринговый компромисс React-архитектуры: либо быстрый TTFB со статической генерацией (без серверной персонализации), либо корректная персонализация с медленным TTFB от SSR. Механизм PPR - пре-генерация статического HTML shell при сборке и стриминг динамического контента в Suspense-границы при запросе - это не улучшение ни одного из подходов, а другая модель. В этой статье разбираю: как работает двухфазный механизм ответа, как правильно архитектурить Suspense-границы, как PPR взаимодействует с Full Route Cache, и что показывают реальные числа в продакшне.
Часть I - Компромисс рендеринга, который разрешает PPR
- ISR (вся страница статична): CDN отвечает за < 50ms TTFB. Но цена может быть неверной для региона пользователя, а инвентарь - устаревшим. Необходимы client-side fetches (
useEffect → fetch → setState), повторно вводящие waterfall-паттерн. - SSR (вся страница динамична): Показывает корректные данные. Но TTFB - 300–800ms (время разрешения всех upstream-зависимостей). TTFB напрямую задерживает LCP: браузер не может нарисовать LCP-элемент до получения первого байта.
- Streaming SSR (без PPR): React может стримить HTML-чанки по мере разрешения данных. Но без PPR весь маршрут по-прежнему динамический - первый байт всё равно результат работы streaming renderer на сервере, не CDN-кэш.
- PPR (статический shell + dynamic holes): Статический контент страницы пре-рендерится при сборке и кэшируется на CDN edge. TTFB < 50ms. Затем открывается стриминговое соединение с динамическим рендерером, который заполняет Suspense-границы по мере разрешения данных. LCP-элемент (герой продукта) - в статическом shell и загружается со скоростью CDN.
Часть II - Механизм двухфазного ответа
PPR разбивает рендеринг маршрута на два этапа: build-time фаза генерирует статический HTML shell (все динамические контексты suspended → Suspense fallbacks), request-time фаза стримит динамический контент в этот shell.
- Chunked transfer encoding: HTTP chunked transfer encoding (RFC 7230) позволяет серверу отправлять ответ по частям без знания общего размера. CDN отправляет статический shell как первый чанк немедленно. Динамический рендерер добавляет последующие чанки (
<script>теги с RSC payload для каждой разрешённой Suspense-границы) в тот же HTTP-ответ по мере готовности. - Артефакт статического shell: Содержит полный HTML-документ:
<html>,<head>со всеми метаданными и<link rel='preload'>хинтами, полное статическое RSC-дерево, Suspense fallback HTML для каждой динамической границы. Браузер получает preload хинты для LCP-изображения в первом чанке. - Изоляция динамического рендерера: Динамическая часть PPR-маршрута деплоится как отдельный serverless function invocation, выполняющий только Suspense-обёрнутые поддеревья.
Часть III - Архитектура Suspense-границ
- Что форсирует динамический рендеринг (должно быть внутри Suspense):
cookies(),headers(),searchParams,fetch()сcache: 'no-store', любые данные, требующие request-специфического контекста (ID пользователя из сессии, регион из cookie). - Что безопасно статично (должно быть вне Suspense): Заголовок продукта, описание, изображения, навигация, footer, schema.org, breadcrumbs, метаданные страницы.
- Анти-паттерн: Обернуть весь
<main>в одну Suspense-границу - функционально эквивалентно full SSR со skeleton loader. Правильная гранулярность: одна Suspense-граница на *каждую независимую динамическую задачу*. Независимые границы стримятся параллельно. - `searchParams` - самая частая PPR-ловушка: Компонент в дереве статического shell, обращающийся к
searchParams, переводит весь маршрут в динамический. Решение: перенести всё потреблениеsearchParamsв Suspense-обёрнутые дочерние компоненты.
Часть IV - Конфигурация и кейсы применения
- Инкрементальный режим (рекомендуется для продакшна): В
next.config.ts:experimental: { ppr: 'incremental' }. Per-route:export const experimental_ppr = true;вpage.tsxилиlayout.tsx. - eCommerce PDP (наибольший эффект): Статично: заголовок, изображение, описание, характеристики. Динамично (Suspense): цена, инвентарь, Add to Cart, рекомендации. LCP-улучшение: 1.8–2.4s (SSR) → 0.6–1.0s (PPR). Подробная архитектура - в гайде по headless Shopify.
- Маркетинговые страницы с A/B тестами: Статично: весь маркетинговый контент. Динамично: назначение A/B варианта из cookie. Без PPR: любое чтение cookie форсирует SSR всей страницы.
- Аутентифицированные дашборды: Статично: структура навигации, sidebar. Динамично: имя пользователя, нотификации, лента активности.
- Блог: Статично: весь контент статьи (~95% страницы). Динамично: статус авторизации, персонализированные CTA.
Часть V - Дизайн fallback, Full Route Cache и результаты
- Нулевой CLS: Suspense fallback-компоненты должны иметь явные размеры, соответствующие реальному контенту. Skeleton UI с explicit dimensions: CLS = 0. Плохо спроектированные fallbacks (display: none → 200px блок): CLS > 0.1.
- PPR восстанавливает Full Route Cache для персонализированных маршрутов:
cookies()внутри Suspense-границы не загрязняет кэшируемость всего маршрута. Статический shell хранится в Full Route Cache / CDN-кэше вне зависимости от содержимого Suspense-границ. - Измеренные результаты: TTFB: full SSR 300–800ms p75 → PPR CDN shell 20–80ms p75 (улучшение 4–10×). LCP на PDP: 1.8–2.4s → 0.6–1.2s. Детальная методология измерения LCP - в The Universal Web Performance Architecture.
Часть VI - Фреймворк принятия решений и ограничения
- Используйте PPR когда: LCP-элемент в статическом контенте; динамический контент изолируем в Suspense-границах; текущий TTFB > 300ms из-за upstream-зависимостей; CWV показывают LCP > 2.5s p75.
- Не используйте PPR когда: Вся видимая часть страницы динамическая;
searchParamsуправляет всем лейаутом (страница результатов поиска); платформа не поддерживает CDN+streaming архитектуру PPR. - Известные ограничения:
searchParamsв дереве статического shell - переводит весь маршрут в динамический; Middleware, мутирующий заголовки ответа, может неожиданно взаимодействовать с PPR; cold start latency динамического рендерера; инкрементальный режим production-стабилен, глобальныйppr: true- ещё experimental; ошибки в dynamic holes (API timeout) изолированы внутри Suspense-границы - статический shell остаётся виден. - Подробный анализ взаимодействия PPR с App Router rendering model - в гайде по App Router миграции.
Заключение
PPR - наиболее значимое изменение архитектуры рендеринга в Next.js со времени введения RSC. Он разрешает структурное ограничение, которое ни ISR, ни SSR, ни streaming SSR не устранили: невозможность раздавать CDN-кэшированный HTML с TTFB < 50ms на маршрутах с любым серверным динамическим контентом. Для аудита архитектуры PPR или Core Web Vitals-инженерии - сервис оптимизации производительности | кейсы | обсудить проект.
Источники
- Next.js Team. 'Partial Prerendering': https://nextjs.org/docs/app/building-your-application/rendering/partial-prerendering
- Vercel. 'Partial Prerendering with Next.js': https://vercel.com/blog/partial-prerendering-with-nextjs-creating-a-new-default-rendering-model
- Next.js Team. 'Next.js 15 Release Notes': https://nextjs.org/blog/next-15
- React Team. 'Suspense Reference': https://react.dev/reference/react/Suspense
- Google. 'Largest Contentful Paint': https://web.dev/articles/lcp
- Google. 'Cumulative Layout Shift': https://web.dev/articles/cls
- Next.js Team. 'Full Route Cache': https://nextjs.org/docs/app/building-your-application/caching#full-route-cache
