Протягом усієї історії веб-розробки інженерам доводилося йти на архітектурний компроміс між статичною генерацією (SSG) та динамічним серверним рендерингом (SSR):
Спроби вирішити цю проблему на стороні клієнта (роздача статичного скелетона з подальшим клієнтським fetch у useEffect) призводять до деградації метрик Core Web Vitals: зсувів верстки (CLS), затримки інтерактивності (INP) та мережевих водоспадів.
Partial Prerendering (PPR) усуває цю дилему на фундаментальному рівні. PPR дозволяє скомпілювати статичну оболонку сторінки (Layouts, навігацію, футер, резервні скелетони) під час збірки проєкту, а динамічні зони інтерфейсу відрендерити на сервері в момент надходження запиту користувача та доставити в браузер через безперервний HTTP-стрімінг у те саме мережеве з'єднання.
Partial Prerendering розділяє життєвий цикл сторінки на дві ізольовані фази виконання:
Фаза статичної збірки (Build-time Prerender Pass):
Компілятор Next.js обходить дерево компонентів під час next build. Статичні частини маршруту компілюються в незмінний Static Shell. Коли рендерер зустрічає межу <Suspense>, всередині якої викликаються динамічні функції (await cookies(), await headers(), доступ до searchParams), рантайм використовує React Postpone API. Виконання цієї динамічної гілки заморожується, у Static Shell записується резервний скелетон fallback, а в розмітці створюється маркер Dynamic Hole (Динамічний слот). Готовий Static Shell завантажується в кеш Edge CDN.
Фаза обробки запиту (Request-time Resume Pass): Коли користувач запитує URL:
ФАЗА 1: ЗБІРКА ПРОЄКТУ (next build / Build-time Prerender Pass)
─────────────────────────────────────────────────────────────────────────────
Компілятор обходить дерево компонентів маршруту:
[Root Layout] ──► [Navigation] ──► [Static Products] ──► [<Suspense>]
│
Зустрінуто cookies()!
│
React.postpone() ──┐
▼
[Збереження у Static Shell (HTML + RSC)] ◄── [Вставка CartSkeleton fallback]
│
▼
Завантаження в Edge CDN Cache
ФАЗА 2: ОБРОБКА ЗАПИТУ КОРИСТУВАЧА (Request-time Resume Pass)
─────────────────────────────────────────────────────────────────────────────
Браузер Edge CDN Serverless Worker (Origin)
│ │ │
(1)├─── GET /shop ──────────►│ │
│ ├── Миттєве скидання Shell (TTFB)──►│
│◄─── Чанк 1: Static Shell┤ [Користувач бачить FCP за 30мс!] │
│ (з CartSkeleton) │ │
│ │ Відновлення з точки postpone:
│ │ const cookie = await cookies();
│ │ const cart = await fetchCart();
│ │ │
(2)│ │◄── Чанк 2: Динамічний слот ───────┤
│◄── Заміна Skeleton ─────┤ HTML кошика + $RC() │
│ на живий контент │ │Next.js збирає статичний каркас сторінки. Зустрічаючи динамічні API всередині Suspense, компілятор призупиняє рендер через React.postpone() і зберігає fallback-скелетон.
Користувач відкриває сторінку. Edge CDN негайно надсилає закешований Static Shell. Браузер відображає інтерфейс за 20-30 мс.
Сервер застосунку відновлює виконання суворо всередині динамічних слотів, зчитуючи актуальні кукі та виконуючи запити до БД.
Готовий динамічний HTML та RSC Flight Payload надсилаються у відкрите HTTP-з'єднання без повного перезавантаження документа.
Інлайновий скрипт React атомарно підміняє скелетон готовим контентом, після чого активується селективна гідратація інтерактивних елементів.
Що відбудеться на етапі статичної збірки (next build), якщо компонент із experimental_ppr = true звернеться до await cookies() БЕЗ огортання в <Suspense>?
next.config.ts#У сучасних версіях Next.js підтримка Partial Prerendering активується через експериментальний блок конфігурації. Опція 'incremental' дозволяє впроваджувати PPR поетапно для окремих маршрутів:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
// 'incremental' дозволяє вмикати PPR точково через export const experimental_ppr = true
ppr: 'incremental',
},
};
export default nextConfig;Щоб увімкнути Partial Prerendering для конкретної сторінки або макета, експортуйте константу experimental_ppr:
import { Suspense } from 'react';
import { StaticCatalogHeader } from '@/components/StaticCatalogHeader';
import { StaticProductGrid } from '@/components/StaticProductGrid';
import { PersonalizedCartWidget } from '@/components/PersonalizedCartWidget';
import { CartSkeleton } from '@/components/skeletons';
// Вмикаємо Partial Prerendering для цього маршруту
export const experimental_ppr = true;
export default function CatalogPage() {
return (
<main className="max-w-7xl mx-auto p-6 space-y-8">
{/* Статична частина: збирається під час збірки (Build-time) */}
<StaticCatalogHeader />
<div className="flex justify-between items-start gap-8">
{/* Статична сітка товарів: віддається з Edge CDN миттєво */}
<div className="flex-1">
<StaticProductGrid />
</div>
{/* Межа Suspense оголошує динамічний слот (Dynamic Hole) */}
<aside className="w-80">
<Suspense fallback={<CartSkeleton />}>
{/* Цей компонент звертається до cookies() і рендериться під час запиту */}
<PersonalizedCartWidget />
</Suspense>
</aside>
</div>
</main>
);
}Компонент автоматично класифікується як динамічний слот, якщо всередині його дерева викликів зустрічаються:
await cookies() із 'next/headers' (асинхронно в Next.js 15+)await headers() із 'next/headers' (асинхронно в Next.js 15+)searchParams сторінки (params: Promise<...>, searchParams: Promise<...>)await connection() із 'next/server' (явний тригер відкладеного динамічного стрімінгу)fetch(..., { cache: 'no-store' })В інтернет-магазині каталог товарів однаковий для всіх покупців, але віджет кошика в шапці прив'язаний до сесійної куки користувача:
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
export async function PersonalizedCartWidget() {
// У Next.js 15+ виклик cookies() суворо асинхронний!
const cookieStore = await cookies();
const sessionToken = cookieStore.get('session_token')?.value;
if (!sessionToken) {
return <div className="text-sm text-text-muted">Кошик порожній</div>;
}
// Запит виконується тільки для конкретного вхідного запиту
const cart = await db.cart.findUnique({
where: { sessionToken },
include: { items: true },
});
const count = cart?.items.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
return (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-border bg-bg-elevated">
<span className="text-sm font-medium text-text-primary">Товарів у кошику:</span>
<span className="font-mono font-bold text-accent-cyan">{count}</span>
</div>
);
}connection()#Якщо компонент не використовує кукі, але виконує важкі аналітичні запити або завантажує живі курси валют, які не мають сенсу зберігатися під час збірки, використовуйте утиліту connection():
import { connection } from 'next/server';
import { fetchLiveExchangeRates } from '@/lib/finance';
export async function LiveStockTicker() {
// await connection() явно викликає React.postpone() на етапі збірки!
// Це повідомляє компілятору: не намагайся запустити цей код під час збірки,
// виконай його суворо після відкриття клієнтського з'єднання.
await connection();
const rates = await fetchLiveExchangeRates();
return (
<div className="flex gap-4 text-xs font-mono py-1 overflow-x-auto text-text-muted">
<span>USD/UAH: {rates.USDUAH}</span>
<span>EUR/UAH: {rates.EURUAH}</span>
<span>BTC/USD: {rates.BTCUSD}</span>
</div>
);
}Що виконує сервер застосунку при надходженні запиту до сторінки з PPR, якщо Static Shell уже закешований на Edge CDN?
Якщо при увімкненому PPR ви викличете асинхронний динамічний метод (await cookies(), await headers(), звернення до searchParams) всередині серверного компонента, який не ізольований межею <Suspense>, Next.js не зможе згенерувати Static Shell та перерве збірку з помилкою.
Рішення: Будь-який компонент, що звертається до динамічного контексту запиту, зобов'язаний бути інкапсульований у <Suspense fallback={<Skeleton />}>.
Error: Route /shop with `experimental_ppr` enabled accessed dynamic data without a Suspense boundary.Кореневі макети (layout.tsx) у Next.js навмисно не мають доступу до пропсу searchParams. Макети персистентні і не перерендериваються при навігації між сторінками, що дозволяє ефективно кешувати їх у Static Shell.
Рішення: Зчитуйте searchParams виключно всередині page.tsx або в ізольованих клієнтських компонентах через хук useSearchParams().
Оскільки відповідь при PPR починається зі статичного чанка з CDN, а завершується динамічним потоком, сторонні корпоративні проксі-сервери або неправильно налаштований Nginx можуть спробувати закешувати всю відповідь повністю (включаючи приватні дані кошика та профілю користувача з динамічного стріму!).
Рішення: При самостійному хостингу (Self-hosted Docker) переконайтеся, що ваш зворотний проксі підтримує Transfer-Encoding: chunked і не буферизує відповіді із заголовком Cache-Control: private, no-cache, no-store.
Створення десятків мікроскопічних меж Suspense (наприклад, навколо кожного текстового поля) фрагментує мережевий потік на сотні дрібних чанків, збільшуючи накладні витрати протоколу TCP/TLS. Групуйте пов'язані динамічні блоки в єдині логічні контейнери.