На протяжении всей истории веб-разработки инженерам приходилось идти на архитектурный компромисс между статической генерацией (SSG) и динамическим рендерингом (SSR):
Попытки решить эту проблему на стороне клиента (раздача статического каркаса с последующим клиентским fetch в useEffect) приводят к деградации метрик Core Web Vitals: скачкам вёрстки (CLS), задержке интерактивности (INP) и сетевым водопадам.
Partial Prerendering (PPR) устраняет эту дилемму на фундаментальном уровне. PPR позволяет скомпилировать статическую оболочку страницы (Layouts, навигацию, футер, резервные скелетоны) при сборке проекта, а динамические зоны интерфейса отрендерить на сервере в момент пользовательского запроса и доставить в браузер через бесшовный HTTP-стриминг в то же самое сетевое соединение.
Partial Prerendering разделяет жизненный цикл страницы на два изолированных этапа:
Фаза сборки (Build-time Prerender Pass):
Компилятор Next.js обходит дерево компонентов. Статические части страницы компилируются в постоянный 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 16)await headers() из 'next/headers' (асинхронно в Next.js 16)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 16 вызов 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/RUB: {rates.USDRUB}</span>
<span>EUR/RUB: {rates.EURRUB}</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. Группируйте связанные динамические блоки в единые логические контейнеры.