Partial Prerendering у Next.js 16: поєднуємо статику та динаміку на одному маршруті

Partial Prerendering у Next.js 16 стабілізовано через Cache Components. Розбираємо статичний shell і динамічні holes на Suspense, міграцію з experimental.ppr, продакшн-патерни, скелетони без CLS та часту помилку "Uncached data was accessed outside of <Suspense>".

Next.js 16 PPR: Cache Components Guide 2026

Отже, у Next.js 16 Partial Prerendering (PPR) нарешті вийшов зі стадії експерименту та став стабільною стратегією рендерингу. Чесно кажучи, для мене це один з найбільш довгоочікуваних апдейтів року — стара суперечка «статика проти динаміки» нарешті втрачає сенс. Тепер можна мати й те, й інше на одному маршруті, в одній HTTP-відповіді. У цьому посібнику розберемо, як PPR працює під капотом, як увімкнути його через Cache Components, де правильно ставити межі Suspense, і які продакшн-патерни я б рекомендував взяти на озброєння у 2026 році.

Що таке Partial Prerendering і чому це важливо

Раніше Next.js змушував вибирати для кожного маршруту між двома крайнощами. Або статичний HTML, який рендериться під час збірки — миттєвий TTFB, але жодних персоналізованих даних. Або повний SSR, де кожен запит чекає, поки відпрацюють усі залежності. Середини, по суті, не існувало.

Partial Prerendering руйнує цю дихотомію на рівні компонентів. Під час збірки Next.js генерує статичну HTML-оболонку (shell) разом із postponedState — спеціальним blob-ом, який описує «дірки» (holes), куди потім буде стримитися динамічний контент. Під час запиту сервер миттєво віддає цю оболонку користувачеві, а динамічні частини рендеряться паралельно та стримляться у тій самій відповіді.

Результат? Користувач бачить хедер, навігацію, скелетони і весь статичний контент одразу. А персоналізовані блоки (кошик, рекомендації, баланс, ціни з урахуванням валюти користувача) з'являються трохи пізніше — у межах тієї самої сторінки, без жодних окремих round-trip запитів із клієнта.

Ключова ментальна модель

Правило одне і просте: усе поза межами <Suspense> — статичне, усе всередині — динамічне. Next.js сам подбає про кешування оболонки на edge, стримінг динамічного контенту з origin і поступове оновлення відповіді у міру готовності даних. Це звучить майже магічно, але магії тут немає — просто акуратно спроектована React-архітектура.

Що змінилося в Next.js 16: Cache Components замість experimental.ppr

Якщо ви вже пробували PPR у Next.js 15, вам знайома директива experimental.ppr: 'incremental' у next.config.ts. Так от, у Next.js 16 цей прапорець повністю видалено. Його замінила нова конфігурація Cache Components — один прапорець, що одночасно вмикає PPR, стабільну директиву 'use cache', нову семантику кешування та інкрементальний префетчинг.

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true, // вмикає PPR + 'use cache' + нову семантику
}

export default nextConfig

І ось головне ментальне зрушення, яке я раджу прийняти одразу: у Next.js 16 весь код динамічний за замовчуванням. Щоб шматки потрапили у статичну оболонку, їх треба або позначити директивою 'use cache', або структурувати дерево так, щоб динамічні частини опинилися всередині <Suspense>. Це інверсія поведінки попередніх версій, де все було статичним, поки ви не натрапляли на cookies(), headers() або неконсистентний fetch. Звикнути треба, але логіка насправді чистіша.

Базовий приклад PPR-маршруту

Розглянемо сторінку продукту, на якій каталожна інформація — статична, а наявність на складі та персональна ціна — динамічні. Класичний сценарій e-commerce.

// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { ProductInfo } from '@/components/product-info'
import { StockStatus } from '@/components/stock-status'
import { PersonalizedPrice } from '@/components/personalized-price'
import { StockSkeleton, PriceSkeleton } from '@/components/skeletons'

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params

  return (
    <article>
      {/* Статична оболонка — у кешованій частині */}
      <ProductInfo productId={id} />

      {/* Динамічна "дірка" — наявність */}
      <Suspense fallback={<StockSkeleton />}>
        <StockStatus productId={id} />
      </Suspense>

      {/* Динамічна "дірка" — персональна ціна */}
      <Suspense fallback={<PriceSkeleton />}>
        <PersonalizedPrice productId={id} />
      </Suspense>
    </article>
  )
}

Компонент <ProductInfo> має бути позначений 'use cache' або складатися тільки зі статичних даних. Наявність та ціна стримляться паралельно — друга «дірка» не чекає першу. Користувач бачить назву, опис і фотографії продукту миттєво, а інша інформація «дозавантажується» прямо на місці.

Приклад кешованого компонента зі статичною логікою

// components/product-info.tsx
import { cacheLife, cacheTag } from 'next/cache'
import { getProduct } from '@/lib/db'

export async function ProductInfo({ productId }: { productId: string }) {
  'use cache'
  cacheLife('days')
  cacheTag(`product:${productId}`)

  const product = await getProduct(productId)

  return (
    <header>
      <h1>{product.title}</h1>
      <p>{product.description}</p>
      <img src={product.image} alt={product.title} />
    </header>
  )
}

Тепер <ProductInfo> увійде у статичний shell, а інвалідація відбуватиметься через revalidateTag('product:{id}') у Server Action — наприклад, коли адмін оновлює картку товару у CMS.

Де розташовувати межі Suspense: архітектурні патерни

Одне з найчастіших питань, яке я чую після увімкнення Cache Components: «Куди саме ставити <Suspense>?». Коротка відповідь — якомога ближче до компонента, який справді потребує request-time даних. Це максимізує статичну частину та дозволяє оболонці містити якомога більше корисного контенту.

Патерн 1: «Міні-острівці» динаміки

Замість загортати весь блок <main> в один Suspense, розділіть його на кілька маленьких. Кожна «дірка» стримиться незалежно, і повільний upstream не блокує інші. Виглядає банально, але на практиці це рятує метрики.

// Погано — один великий Suspense блокує весь блок
<Suspense fallback={<BigSkeleton />}>
  <Stock />
  <Price />
  <Recommendations />
</Suspense>

// Добре — три незалежних stream-и
<Suspense fallback={<StockSkeleton />}>
  <Stock />
</Suspense>
<Suspense fallback={<PriceSkeleton />}>
  <Price />
</Suspense>
<Suspense fallback={<RecSkeleton />}>
  <Recommendations />
</Suspense>

Патерн 2: Статичний layout, динамічна page

Layouts зазвичай містять хедер, сайдбар і футер — усе це чудовий кандидат на включення у shell. Залиште динамічні частини всередині page.tsx, щоб layout цілком кешувався між маршрутами. Простий патерн, величезна економія на рендері.

Патерн 3: Розділення читача (reader) від рендера

Якщо у вас є компонент, який одночасно читає cookies/headers і рендерить UI, винесіть читання в окремий серверний компонент, обгорнутий Suspense. Це дозволяє рендерити UI-шеллі навколо динамічної частини, а не втрачати весь блок у динаміку.

// app/dashboard/page.tsx
export default function DashboardPage() {
  return (
    <main>
      <h1>Панель користувача</h1> {/* статика */}
      <nav>/* статичні посилання */</nav>

      <Suspense fallback={<GreetingSkeleton />}>
        <UserGreeting />  {/* читає cookies() тут, не в layout */}
      </Suspense>

      <Suspense fallback={<StatsSkeleton />}>
        <UserStats />
      </Suspense>
    </main>
  )
}

Найпоширеніша помилка: «Uncached data was accessed outside of <Suspense>»

Якщо після увімкнення Cache Components складання валиться з такою помилкою — вітаю, ви десь у дереві компонентів викликаєте request-time API (cookies(), headers(), draftMode(), connection() або fetch без 'use cache') і не обгорнули цю частину в Suspense. Особисто я ловив цю помилку разів п'ять у першу добу міграції, тож не лякайтесь — вона радше ваш друг, ніж ворог.

Алгоритм усунення:

  1. Прочитайте stack trace — Next.js точно вкаже файл і компонент.
  2. Якщо дані справді request-time (персональні), обгорніть компонент у <Suspense fallback={...}>.
  3. Якщо дані можна кешувати, додайте 'use cache' у верху функції і налаштуйте cacheLife/cacheTag.
  4. Частий випадок: виклик у layout.tsx. Винесіть його в окремий компонент і обгорніть Suspense'ом усередині layout'а.

У build-логах шукайте індикатор перед маршрутом. Символ означає PPR-маршрут (static shell + dynamic holes), — повністю статичний, λ — повністю динамічний (тобто PPR не спрацював). Якщо бачите λ там, де чекали , — десь у layout/page витік request-time виклику. Це перше, куди я дивлюсь після кожного білду.

Продакшн-патерни 2026 року

1. Skeletons із явними розмірами — щоб не «стрибнуло» CLS

PPR вводить новий ризик для Cumulative Layout Shift, якого не було у класичному SSR: fallback-скелетон може бути меншим за фінальний контент, і при заміні макет «стрибне». Практика проста — давайте скелетонам фіксовану висоту та ширину, що відповідають реальному UI. Добре спроектовані скелетони дають CLS = 0; погані — CLS > 0.1 і падіння в Lighthouse.

// components/skeletons.tsx
export function StockSkeleton() {
  return (
    <div
      className="animate-pulse bg-gray-200"
      style={{ height: 24, width: 180 }}
      aria-label="Завантаження наявності"
    />
  )
}

2. Error Boundaries як партнер Suspense

Якщо у «дірці» впаде fetch, PPR гарантує, що статична оболонка залишиться цілою. Додайте error.tsx в сегмент маршруту або обгорніть динамічний компонент у React Error Boundary. Користувач побачить деградований стан лише в одному блоці, а не на всій сторінці — це, на мій погляд, один з найнедооцінених плюсів PPR.

// app/products/[id]/error.tsx
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error
  reset: () => void
}) {
  return (
    <div role="alert">
      <p>Не вдалося завантажити цей блок.</p>
      <button onClick={() => reset()}>Спробувати ще</button>
    </div>
  )
}

3. Паралельні запити в динамічних «дірках»

Кожна Suspense-границя — це окремий стрім. Якщо два незалежних API можна викликати паралельно, винесіть їх у два сусідні <Suspense>, а не в один компонент із послідовними await-ами. Тоді повільніший не тримає швидшого.

4. Edge runtime для shell, Node runtime для динаміки

Статичний shell чудово кешується на edge і віддається ближче до користувача. Динамічні компоненти, які ходять у базу даних, часто зручніше залишити на Node runtime, де є повноцінний driver. PPR дозволяє поєднувати обидва — edge віддає оболонку, origin стримить дірки. Гібрид без додаткових танців з бубном.

5. Progressive enhancement: form actions всередині PPR-сторінки

Server Actions чудово живуть у PPR-сторінках. Форма рендериться як частина shell (або як окрема Suspense-дірка, якщо треба читати сесію), а <form action={serverAction}> відправляється без клієнтського JS. Максимум продуктивності, мінімум bundle-сайзу. Чи не цього ми всі хотіли?

Міграція з Next.js 15 на Next.js 16 PPR

Якщо ви вже жили з experimental.ppr: 'incremental' та export const experimental_ppr = true у своїх сторінках, міграція виглядає приблизно так:

  1. Оновіть Next.js: npm install next@latest react@latest react-dom@latest.
  2. Запустіть офіційний codemod: npx @next/codemod@latest upgrade — він перепише async-API (await cookies(), await headers()) та видалить застарілі прапорці.
  3. Видаліть з next.config.ts секцію experimental.ppr та experimental_ppr зі сторінок.
  4. Додайте cacheComponents: true у корені конфіга.
  5. Запустіть npx next typegen для оновлення глобальних type-помічників.
  6. Зробіть npm run build і пройдіться по всіх помилках «Uncached data was accessed outside of <Suspense>» — це фактично ваш робочий план міграції, видрукуваний самим компілятором.

Інкрементальна адаптація великих кодобаз

Великий додаток не варто перекидати на Cache Components за один день (я пробував — не раджу). Рекомендована стратегія:

  • Почніть з одного second-level layout (наприклад, /app/(marketing)) — там найбільше статики.
  • Додайте 'use cache' на чисті презентаційні компоненти.
  • Профільтруйте виклики cookies()/headers() у dashboard-сторінках і обгортайте їх у локальні Suspense.
  • Після того, як перші маршрути стабілізувалися з у build-виводі, розкочуйте на наступні секції.

Вплив PPR на Core Web Vitals

PPR передусім б'є по двох метриках: TTFB (Time to First Byte) і LCP (Largest Contentful Paint). Статичний shell лежить на CDN — перший байт прилітає за десятки мілісекунд. LCP-елемент (часто зображення або заголовок) входить в оболонку, тож він видимий до того, як резолвились дірки.

TTI (Time to Interactive) змінюється менше, бо гідрація йде вже після завантаження відповідних JS-чанків. CLS, як ми вже казали, вимагає акуратних скелетонів.

Важливо для SEO: статичний контент віддається пошуковим роботам миттєво і завжди. Googlebot індексує shell, не чекаючи на гідрацію або динамічні дірки. Це усуває класичну проблему «SSR віддає порожню сторінку, якщо backend повільний» — і, будемо відверті, саме на цьому здулось чимало маркетингових сайтів торік.

Коли PPR — не найкращий вибір

PPR має бути дефолтною стратегією, але є сценарії, де він менш корисний:

  • Повністю персоналізовані сторінки, де кожен піксель залежить від сесії (банківський кабінет, адмін-панель). Там PPR зводиться до звичайного стримінгу.
  • API-ендпоінти (Route Handlers) — PPR їх не стосується, бо вони не повертають HTML.
  • Надкороткі динамічні сторінки, де статичної частини фактично немає — наприклад, endpoint-редіректи або generated-image routes.
  • Сценарії суворого A/B-тестування, де shell мусить відрізнятися між когортами — у таких випадках розгляньте Edge Middleware з rewrite-ом.

Чекліст готовності PPR-маршруту до продакшну

  • У next.config.ts увімкнено cacheComponents: true.
  • У build-виводі маршрут позначено символом .
  • Кожен Suspense має скелетон із явними розмірами (CLS = 0).
  • Для кожного сегмента маршруту є error.tsx або React Error Boundary.
  • Виклики cookies()/headers() є тільки всередині Suspense, а не в layout/page верхнього рівня.
  • Статичні компоненти позначені 'use cache' з відповідними cacheTag.
  • Для інвалідації пишуться revalidateTag або updateTag у Server Actions.
  • Ви перевірили TTFB, LCP і CLS у WebPageTest або Lighthouse.

Поширені запитання (FAQ)

Чим PPR відрізняється від SSG, SSR та ISR?

SSG генерує всю сторінку під час збірки, SSR — повністю на кожному запиті, ISR періодично перегенеровує статику. PPR — гібрид на рівні компонентів: оболонка статична (як у SSG), а всередині живуть дірки, які рендеряться на запит (як у SSR) і стримляться паралельно. Жодна з трьох попередніх моделей не давала цього в одній HTTP-відповіді.

Чи треба видаляти experimental_ppr = true зі сторінок після переходу на Next.js 16?

Так. У Next.js 16 цей прапорець видалено разом з усією опцією experimental.ppr. Його замінює глобальне cacheComponents: true. Codemod npx @next/codemod@latest upgrade зробить це автоматично.

Чому моя сторінка відображається як λ (dynamic), а не ◐ (PPR)?

Найчастіша причина — request-time API викликається поза межами Suspense. Перевірте layout.tsx та page.tsx на наявність cookies(), headers(), draftMode() або fetch-ів без 'use cache'. Перенесіть їх у дочірній компонент, обгорнутий <Suspense>.

Чи погіршує PPR SEO через відкладений контент у «дірках»?

Навпаки, покращує. Статична оболонка індексується миттєво, а основні мета-теги та LCP-елемент віддаються у першому байті. Динамічні «дірки» доходять стримом у тій самій HTTP-відповіді, тож Googlebot бачить їх без окремого клієнт-сайд рендеру.

Що робити, якщо dynamic-хол повернув помилку?

Обгорніть сегмент у error.tsx або React Error Boundary. Shell залишиться цілим, а користувач побачить локальну деградацію замість 500-ї на всю сторінку. Це архітектурна перевага PPR над класичним SSR.

Чи сумісний PPR з Edge Runtime?

Так. Більше того, edge ідеально підходить для віддачі статичного shell — він кешується найближче до користувача. Динамічні дірки можна тримати на Node runtime, якщо вони ходять у БД через нативні драйвери. PPR прозоро поєднує обидва рантайми у межах одного маршруту.

Висновок

Partial Prerendering у Next.js 16 — це не «ще одна опція рендерингу», а новий дефолт. Він знімає старий трейдоф між швидкістю і персоналізацією: ви одночасно отримуєте миттєвий TTFB статичного shell і свіжі request-time дані у «дірках». Ціна входу невелика — увімкнути cacheComponents: true, обгорнути динамічні частини у Suspense і продумати скелетони. А віддача — помітне покращення Core Web Vitals і набагато стійкіша до помилок архітектура. Якщо ви ще не почали міграцію, моя порада проста: починайте з одного маршруту, проведіть його через повний чекліст, а далі розкочуйте на всю кодобазу.

Про Автора Editorial Team

Our team of expert writers and editors.