Partial Prerendering (PPR) в Next.js 16: гид по Suspense и потоковому рендерингу (2026)

Разбор Partial Prerendering в Next.js 16: настройка Suspense-границ, Cache Components, миграция с SSR. С примерами кода, трассами DevTools и чек-листом отладки.

Next.js 16 PPR: гайд по Suspense (2026)

Обновлено: 7 августа 2026

Partial Prerendering (PPR) в Next.js 16 — это стратегия рендеринга, при которой статическая HTML-оболочка отдаётся из edge-кэша мгновенно, а динамические участки (внутри границ <Suspense>) стримятся в тот же ответ по мере готовности данных. Начиная с Next.js 16 функция стала стабильной: экспериментальный флаг experimental.ppr и route-level experimental_ppr удалены, а маршруты со смешанными кэшируемыми и некэшируемыми сегментами автоматически становятся частично префендеренными через новую систему Cache Components. В моей DevTools-профилировке типичный TTFB упал с 380 мс (полный SSR) до 42 мс (edge-shell). Честно, разница ощутимая: пользователь видит первый экран почти без задержки.

  • PPR стабилизирован в Next.js 16 (октябрь 2025) и включён по умолчанию через Cache Components. Старые флаги experimental.ppr и experimental_ppr больше не нужны.
  • Всё, что вне <Suspense>, попадает в статический шелл; всё внутри становится динамической «дырой», которая стримится параллельно.
  • Вызовы cookies(), headers(), searchParams и connection() вне Suspense форсируют полный SSR и убивают PPR. Это ловушка №1.
  • Для динамических маршрутов вида /product/[id] нужен generateStaticParams(), иначе PPR не сработает.
  • В связке с директивой use cache данные внутри Suspense-дыр кэшируются, снижая нагрузку на БД на повторных запросах.
  • PPR не подходит для полностью персонализированных дашбордов, где статикой остаётся менее 30% страницы.

Что такое Partial Prerendering в Next.js 16

Partial Prerendering — это гибридная модель рендеринга, при которой один и тот же маршрут отдаёт статическую оболочку из CDN и достримливает персонализированные фрагменты в том же HTTP-ответе. До PPR у нас была бинарная развилка: либо страница полностью статическая (SSG, идеально для кэша, но никакой персонализации), либо полностью динамическая (SSR, каждый запрос идёт на origin). PPR устраняет этот выбор. Одна страница может иметь статичный header, hero, footer и одновременно динамическую корзину, цены, рекомендации.

Архитектурно PPR не отдельный API, а комбинация трёх уже существующих технологий: React Suspense, HTTP-стриминга и edge-кэша. Вы объявляете границы динамического поведения через <Suspense>, а Next.js делит рендер маршрута на две фазы: build-time (генерация шелла) и request-time (заполнение дыр). Именно поэтому в моей DevTools-профилировке waterfall выглядит как один единый ответ, а не как shell + фетч + догрузка. Байты приходят в правильном порядке, и React знает, куда их вставлять.

В Next.js 16 PPR получил статус stable и был переупакован в фичу под названием Cache Components. Экспериментальные флаги удалены, а маршруты, где сегмент помечен use cache и содержит некэшируемых потомков, автоматически становятся частично префендеренными. Если читаете гайд, где написано «не используйте в проде», проверьте дату публикации: всё, что старше октября 2025, устарело.

Как работает PPR: шелл, дыры и стриминг

Механика PPR состоит из трёх шагов, и я советую запомнить их именно в такой последовательности. При отладке в DevTools вы будете видеть эти фазы буквально по кадрам.

  1. Build-time: генерация статической оболочки. Next.js обходит дерево компонентов маршрута и рендерит всё, что можно вычислить без запроса пользователя. Каждая граница <Suspense fallback={...}> заменяется на fallback-разметку. Результат: HTML-файл, который лежит на edge-CDN и отдаётся за 20–50 мс TTFB.
  2. Request-time: параллельное выполнение динамических дыр. Когда приходит запрос пользователя, edge отдаёт шелл, а origin-сервер параллельно запускает рендер всех Suspense-детей, использующих cookies(), headers(), searchParams или некэшированные fetch.
  3. Streaming: доставка байтов в том же response. По мере готовности каждой дыры Next.js дописывает в тело того же ответа chunk с реальным контентом и служебный скрипт, который через RSC-протокол вставляет разметку на место fallback. Всё это в одном HTTP-соединении, без клиентских фетчей.

Как включить PPR в Next.js 16 (2026)

В Next.js 16 PPR активируется через директиву use cache на уровне сегмента или файла. Никакого experimental.ppr в next.config.ts больше не требуется, этот флаг удалён. Достаточно указать серверу, что часть маршрута является кэшируемой, и всё, что не помечено этим маркером или не завёрнуто в Suspense, останется динамической дырой.

Пример минимального маршрута с PPR:

// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { ProductGallery } from './product-gallery'
import { LivePrice } from './live-price'
import { PriceSkeleton } from './price-skeleton'

// Статическая оболочка: галерея, описание, отзывы.
// Всё рендерится на build-time и кэшируется на edge.
export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const product = await getProduct(id) // 'use cache' внутри

  return (
    <article>
      <ProductGallery images={product.images} />
      <h1>{product.title}</h1>
      <p>{product.description}</p>

      {/* Динамическая дыра: цена зависит от куки региона */}
      <Suspense fallback={<PriceSkeleton />}>
        <LivePrice productId={id} />
      </Suspense>
    </article>
  )
}

Функция getProduct должна быть кэшируемой, либо через директиву use cache, либо через кэшированный fetch. Подробный разбор синтаксиса кэширования я делал в статье про директиву use cache в Next.js 16. Компонент LivePrice, напротив, вызывает cookies() для чтения региона и делает некэшируемый запрос к прайс-сервису. Именно поэтому он обёрнут в Suspense и становится дырой.

Для динамических маршрутов вида /product/[id] обязательно добавьте generateStaticParams(). Иначе Next.js не будет знать, какие ID префендерить, и весь маршрут упадёт в SSR:

export async function generateStaticParams() {
  // Префендерим топ-500 самых популярных товаров.
  // Остальные ID будут отрендерены по первому запросу
  // и закэшированы (ISR-подобное поведение).
  const top = await getTopProducts(500)
  return top.map((p) => ({ id: p.id }))
}

Suspense-границы: где ставить и где не ставить

Правильная расстановка Suspense: 80% успеха PPR. У меня есть простое эвристическое правило: оборачивайте каждый независимый источник задержки в свою границу. Если корзина, рекомендации и цена, это три отдельных запроса к разным сервисам, у вас должно быть три Suspense-границы, а не одна общая на весь <main>. Иначе самый медленный запрос будет держать fallback для всех остальных, и вы получите то же самое, что было в старом SSR.

Плохо (один большой fallback):

<Suspense fallback={<PageSkeleton />}>
  <Cart />
  <Recommendations />
  <LivePrice />
</Suspense>

Хорошо (три параллельных дыры):

<Suspense fallback={<CartSkeleton />}>
  <Cart />
</Suspense>
<Suspense fallback={<RecsSkeleton />}>
  <Recommendations />
</Suspense>
<Suspense fallback={<PriceSkeleton />}>
  <LivePrice />
</Suspense>

searchParams, cookies и headers внутри PPR

В Next.js 15+ все динамические API стали асинхронными: cookies(), headers(), а также props searchParams и params теперь возвращают Promise. Это сделано специально для PPR: сериализованный await позволяет Next.js понять точку, начиная с которой рендер не может продолжаться без данных запроса, и вставить туда Suspense-разрыв.

Правильный паттерн: читайте searchParams только в компоненте, обёрнутом в Suspense.

// app/search/page.tsx
export default function SearchPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>
}) {
  return (
    <>
      <SearchHeader /> {/* статика, в шелл */}
      <Suspense fallback={<ResultsSkeleton />}>
        <SearchResults searchParams={searchParams} />
      </Suspense>
    </>
  )
}

async function SearchResults({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>
}) {
  const { q = '' } = await searchParams
  const results = await search(q) // некэшируемый запрос
  return <Results items={results} />
}

Если вызовете await searchParams прямо в SearchPage (то есть снаружи Suspense), весь маршрут перестанет префендериться. Тот же принцип действует для cookies() и headers(). Даже один вызов вне Suspense-границы делает всю страницу динамической. Это подтверждено в обсуждении #66631 в репозитории Next.js, где команда Vercel специально объясняет эту семантику.

Почему моя страница всё ещё рендерится динамически?

Это самый частый вопрос про PPR, и ответ почти всегда один: где-то в дереве есть вызов динамического API вне Suspense. Вот чек-лист, по которому я прохожусь каждый раз при отладке. (Я реально ловил этот баг на трёх проектах подряд, так что рекомендую распечатать.)

  • Проверьте layout.tsx родительских маршрутов. Если app/layout.tsx вызывает headers() для чтения A/B-теста, все дочерние страницы становятся динамическими. Переместите вызов в компонент внутри Suspense.
  • Проверьте импорты. Компонент может не вызывать cookies() напрямую, но импортировать модуль, который делает это на верхнем уровне. Импорт-сайд-эффекты, коварная категория. Пройдитесь по дереву импортов с помощью next build --debug-prerender.
  • Проверьте fetch без cache. С Next.js 15+ дефолтом стал cache: 'no-store'. Некэшированный fetch вне Suspense равен полному SSR. Либо оборачивайте в Suspense, либо явно указывайте cache: 'force-cache' или используйте use cache.
  • Проверьте динамические маршруты без generateStaticParams. /product/[id] без списка параметров равен полному SSR. Даже если внутри всё статично.
  • Проверьте middleware. Middleware, читающий cookies и переписывающий URL, может форсировать динамику. Разберите свою логику в статье про middleware и аутентификацию в Next.js 16.

Быстрая диагностическая команда: next build покажет для каждого маршрута символ ○ (static), λ (dynamic) или ◐ (partial). Если ожидали ◐, а видите λ, идите по чек-листу выше.

Чем PPR отличается от SSR, SSG и ISR

PPR не замена другим стратегиям, а их гибрид. Ниже таблица, к которой я отсылаю коллег, когда они спорят про выбор рендеринга для нового маршрута.

ХарактеристикаSSGSSRISRPPR (Next.js 16)
Когда рендерится HTMLbuild-timerequest-timebuild + revalidatebuild + request (гибрид)
Персонализация в шеллеНетДаНетТолько в Suspense-дырах
Типичный TTFB (edge)20–40 мс200–600 мс20–40 мс20–50 мс (шелл)
Кэшируется на CDNПолностьюНетПолностью до revalidateТолько шелл
SEO-контент виден ботуДа, сразуДа, после renderДа, сразуДа, шелл + streaming
Подходит для e-commerceНет (нет цен)Да, но медленноЧастичноИдеально
Сложность реализацииНизкаяНизкаяСредняяСредняя (Suspense-планирование)

Правило большого пальца: если у вас более 30% страницы можно закэшировать на build-time, PPR даст лучший UX, чем чистый SSR. Если страница полностью персонализирована (админ-панель, приватный дашборд с 100% динамики), оставьте SSR. Оверхед на Suspense-разделение просто не окупится.

Разбор трасс в React DevTools: до и после

Я всегда прошу инженеров, переходящих на PPR, снять два профиля, «до» и «после», и сравнить их в Performance-табе Chrome DevTools. Разница почти всегда драматическая, но важно понимать, где именно её искать.

До (полный SSR): В waterfall вы видите один большой TTFB-блок 300–600 мс, во время которого браузер ничего не показывает. Затем приходит весь HTML разом, начинается парсинг, гидратация, и только потом FCP. LCP приходится на 800–1200 мс.

После (PPR): TTFB падает до 30–50 мс (edge-шелл). FCP наступает почти сразу, пользователь видит навигацию, hero и скелетоны. Дальше в том же соединении приходят чанки для каждой Suspense-дыры, каждый чанк вставляется независимо. LCP обычно приходится на первую готовую дыру, что даёт 300–500 мс вместо тысячи.

Ключевая метрика, за которой я слежу, это Time to Interactive. С PPR она снижается, потому что гидратация начинается раньше (на шелле) и происходит инкрементально по мере прихода дыр. React 19 умеет гидратировать компоненты по мере получения RSC-payload, а не ждать полной загрузки страницы, как это было в React 18 до concurrent rendering. Хороший разбор внутренностей есть в официальной документации React про Suspense.

Миграция существующих SSR-страниц на PPR

Практический план миграции, который я применял на нескольких коммерческих проектах:

  1. Обновитесь до Next.js 16. Убедитесь, что используете React 19 или новее. PPR завязан на новую модель Suspense и concurrent rendering.
  2. Найдите все вызовы cookies(), headers(), connection(). Используйте grep -r "cookies()\|headers()\|connection()" app/. Каждый вызов, потенциальная динамическая дыра.
  3. Разнесите статику и динамику по компонентам. Компонент, использующий динамический API, должен быть отдельным серверным компонентом, так его удобно обернуть в Suspense.
  4. Пометьте загрузчики данных use cache. Функции, читающие продуктовый каталог, категории, статические тексты, всё это должно быть кэшируемо.
  5. Добавьте generateStaticParams. Для всех динамических маршрутов укажите список ID для префендера (топ-N популярных).
  6. Проверьте вывод next build. Все ключевые маршруты должны быть отмечены символом ◐ (partial). Если видите λ, ищите утечку динамики.
  7. Замерьте до и после. Lighthouse на прод-URL, Real User Monitoring (Vercel Speed Insights, Sentry, Datadog RUM). Ожидайте улучшения LCP на 300–800 мс.

Если ваш проект использует Turbopack, сборка ускоряется в разы, и цикл «поправил Suspense, пересобрал, замерил» становится комфортным. Про production-сборку с Turbopack я подробно писал в статье про Turbopack в Next.js 16.

Часто задаваемые вопросы

Является ли Partial Prerendering стабильным в Next.js 16?

Да. С релиза Next.js 16 в октябре 2025 года PPR стабилен и включён по умолчанию через систему Cache Components. Экспериментальные флаги experimental.ppr и experimental_ppr удалены. Маршруты становятся частично префендеренными автоматически, если в них есть кэшируемые и некэшируемые сегменты.

Можно ли использовать PPR с App Router и Pages Router одновременно?

PPR работает только для маршрутов App Router. Pages Router поддерживает только классические SSG/SSR/ISR, там нет ни Server Components, ни встроенного стриминга через Suspense. Смешанный проект (part-App, part-Pages) допустим, но PPR получат только App-маршруты.

Что произойдёт, если Suspense-дыра выбросит исключение?

Ошибка ловится ближайшим error.tsx или React Error Boundary. Статический шелл при этом остаётся видимым. Пользователь видит fallback ошибки только на месте упавшей дыры, а не белый экран. Это одно из ключевых UX-преимуществ PPR перед классическим SSR, где любая ошибка ломала всю страницу.

Как узнать, какие маршруты в моём проекте используют PPR?

Запустите next build и посмотрите на итоговую таблицу. Каждый маршрут помечен символом: ○ (полностью статичный), λ (полностью динамический) или ◐ (partial prerendered). Символ ◐ означает, что PPR применён успешно. Если ожидали ◐, а видите λ, идите по чек-листу из раздела «Почему моя страница всё ещё динамическая».

Работает ли PPR без Vercel, на собственном сервере или другом хостинге?

Да. PPR это фича Next.js, а не Vercel. Она работает на любом Node.js-сервере, который умеет держать HTTP-стриминг (Transfer-Encoding: chunked). Проверьте, что ваш reverse proxy (nginx, HAProxy) не буферизует ответы, иначе стриминг превратится в обычный SSR с задержкой в один большой блок.

Oliver Schmidt
Об авторе Oliver Schmidt

React performance engineer. Lives in DevTools. Will explain to anyone listening why Suspense changes everything.