В продължение на десетилетие уеб разработчиците сме били заклещени в един доста неудобен компромис: статичното рендиране (SSG/ISR) ни дава светкавично TTFB, ама не може да показва персонализирани данни. От другата страна, сървърното рендиране (SSR) поддържа персонализация, но блокира цялата страница на най-бавната зависимост. Честно казано, толкова пъти съм избирал между двете и никога не съм бил доволен от резултата. Е, в Next.js 15 нещата се промениха — въведоха Partial Prerendering (PPR), модел, който премахва този компромис, като комбинира двата подхода в един и същ маршрут.
В това ръководство ще разгледаме как PPR работи под капака, как да го активирате в проекта си, как да структурирате <Suspense> границите, и как да оправите най-честите грешки при build-time. Всички примери са актуализирани за Next.js 15.2.3+ и React 19.
Какво е Partial Prerendering (PPR)?
Накратко: PPR е стратегия за рендиране, която позволява една и съща страница да съдържа едновременно статично (предварително генерирано) и динамично (генерирано при заявка) съдържание. И най-готиното — цялото това съдържание се изпраща в една HTTP заявка, без допълнителни round-trips към браузъра.
Идеята е следната. При next build Next.js рендира статичната част на страницата в HTML shell и я кешира на CDN-а. Динамичните секции, обвити в <Suspense>, се оставят като „дупки" с fallback UI. Когато потребителят посети страницата:
- CDN-ът незабавно връща статичния shell — потребителят вижда съдържание за под 100ms.
- В същата HTTP заявка сървърът паралелно стриймва динамичните секции.
- React постепенно заменя fallback-овете с реалното съдържание, докато то пристига.
Защо това е революционно?
Преди PPR имахте три възможности и трябваше да изберете една за цялата страница:
- SSG/ISR — бърз TTFB, но не може да чете
cookies()илиheaders(). - SSR — персонализиран, но всяка заявка чака най-бавния DB call.
- Client-side rendering — flash of loading, лош SEO, по-бавен LCP.
С PPR можете да имате всичко това на една страница. Header-ът с името на потребителя стриймва. Списъкът с продуктите е статичен. Препоръките се изчисляват от ML модел в реално време. Всяка част избира собствената си стратегия — и това е невероятно освобождаващо, ако сте били от хората (като мен), които са правили компромиси месеци наред.
Как да активираме PPR в Next.js 15
В Next.js 15 PPR все още е експериментална функция, но incremental режимът е production-ready (вече се използва от доста екипи в продукция). Активира се с две стъпки.
Стъпка 1: Конфигурация в next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental',
},
}
export default nextConfig
Стойността 'incremental' означава, че PPR не се прилага автоматично на всички страници — трябва да го включите ръчно за всеки маршрут. Това е безопасният начин за миграция на съществуващ проект (и истината е, че никой не иска да започне build и да види, че половината от приложението му се е счупило, нали?).
Стъпка 2: Opt-in в маршрут
В app/dashboard/page.tsx (или в layout-а на сегмента) експортирайте константата:
export const experimental_ppr = true
export default function DashboardPage() {
return (
<main>
<StaticHero />
<Suspense fallback={<UserSkeleton />}>
<UserGreeting />
</Suspense>
</main>
)
}
Флагът се прилага рекурсивно към всички вложени layout-и и страници в сегмента. За да изключите PPR в дъщерен сегмент, задайте experimental_ppr = false.
Suspense граници: разделителната линия
Това е най-важната концепция в PPR — и ако пропуснете само нея, всичко останало няма да се получи. Запомнете я добре: всичко извън <Suspense> е статично. Всичко вътре е динамично.
Когато Next.js срещне <Suspense> по време на build, спира да рендира това поддърво и записва fallback компонента в статичния HTML. По време на runtime сървърът поема щафетата и стриймва истинското съдържание. Просто и елегантно.
Класически пример: Navbar с потребителско меню
import { Suspense } from 'react'
import { cookies } from 'next/headers'
export const experimental_ppr = true
async function UserMenu() {
const cookieStore = await cookies()
const session = cookieStore.get('session')?.value
const user = session ? await getUser(session) : null
return user
? <span>Здравейте, {user.name}</span>
: <a href="/login">Вход</a>
}
export default function Navbar() {
return (
<nav>
<Logo />
<NavLinks />
<Suspense fallback={<div className="w-24 h-8 bg-gray-200 animate-pulse" />}>
<UserMenu />
</Suspense>
</nav>
)
}
В този пример Logo и NavLinks са статични. UserMenu чете cookies(), което е dynamic API — затова е обвит в <Suspense>. CDN-ът връща navbar-а с placeholder за потребителското меню за под 50ms, а самото меню стриймва паралелно. Просто, нали?
Build output индикатори
След next build Next.js показва индикатори за всеки маршрут:
○— напълно статичен (SSG)◐— partial prerender (PPR с динамични дупки)λ— напълно динамичен (SSR)●— статичен с ISR ревалидация
Малък съвет от опит: ако очаквате ◐, но виждате λ, това почти винаги означава, че имате dynamic API извикване извън Suspense boundary. Next.js е принуден да направи цялата страница динамична.
Dynamic IO и автоматичен fallback
В Next.js 15 е въведена концепцията Dynamic IO: всеки асинхронен I/O (fetch, DB заявка, четене на cookies) се счита за динамичен, освен ако изрично не го кеширате с "use cache" или unstable_cache.
Когато компонент изпълни dynamic API, се случва едно от двете:
- Ако е обвит в
<Suspense>→ стриймва се при заявка ✓ - Ако не е обвит → цялата страница пада обратно до пълно SSR ✗
Кои API са „dynamic"?
// Всички тези превключват към dynamic rendering:
import { cookies, headers, draftMode } from 'next/headers'
import { connection } from 'next/server'
await cookies() // Четене на бисквитки
await headers() // Четене на HTTP headers
await draftMode() // Preview/draft режим
await connection() // Изричен dynamic boundary
// Promise props в Server Components:
async function Page({ params, searchParams }) {
const { id } = await params // dynamic
const { q } = await searchParams // dynamic
}
// Некеширан fetch:
await fetch('https://api.example.com/data') // dynamic по подразбиране в 15
Реален пример: E-commerce продуктова страница
Сега да направим нещо по-реалистично. Нека построим продуктова страница с три зони:
- Статично: продуктово описание и снимки (същото за всички).
- Динамично: текущ inventory от warehouse API.
- Динамично: персонализирани препоръки за потребителя.
// app/products/[slug]/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
export const experimental_ppr = true
// Тази заявка се кешира — част от static shell
async function getProduct(slug: string) {
'use cache'
const res = await fetch(`https://cms.example.com/products/${slug}`)
return res.json()
}
// Тази заявка е dynamic — стриймва се
async function InventoryStatus({ productId }: { productId: string }) {
const res = await fetch(
`https://warehouse.example.com/stock/${productId}`,
{ cache: 'no-store' }
)
const { quantity } = await res.json()
return quantity > 0
? <p className="text-green-600">✓ В наличност ({quantity} бр.)</p>
: <p className="text-red-600">Изчерпан</p>
}
// Персонализирани препоръки — четат cookies
async function Recommendations({ productId }: { productId: string }) {
const cookieStore = await cookies()
const userId = cookieStore.get('user_id')?.value
const recs = await fetchRecommendations(userId, productId)
return (
<ul>
{recs.map(r => <li key={r.id}>{r.name}</li>)}
</ul>
)
}
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const product = await getProduct(slug)
return (
<article>
{/* Статичен shell */}
<ProductGallery images={product.images} />
<h1>{product.name}</h1>
<p>{product.description}</p>
<p className="price">{product.price} лв.</p>
{/* Динамична секция 1 */}
<Suspense fallback={<p>Проверка на наличност...</p>}>
<InventoryStatus productId={product.id} />
</Suspense>
{/* Динамична секция 2 */}
<section>
<h2>Препоръчани за вас</h2>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations productId={product.id} />
</Suspense>
</section>
</article>
)
}
Резултатът? Продуктовият shell се сервира за ~30ms от CDN-а. Inventory статусът и препоръките стриймват паралелно за общо ~200ms. LCP под 1 секунда дори с персонализирано съдържание. Преди година нещо подобно беше практически невъзможно без сериозни компромиси.
Често срещани грешки и решения
1. „Cannot access cookies/headers without Suspense boundary"
Тази грешка се появява, когато извиквате dynamic API в page.tsx или layout.tsx на най-високо ниво — извън Suspense. И вярвайте ми, всеки, който е работил с PPR, е попадал на нея поне веднъж.
Решение: Преместете dynamic логиката в отделен компонент и го обвийте в <Suspense>:
// ❌ Грешно
export default async function Layout({ children }) {
const session = await cookies() // блокира цялата страница
return <div>{children}</div>
}
// ✓ Правилно
async function SessionProvider({ children }) {
const session = await cookies()
return <Context.Provider value={session}>{children}</Context.Provider>
}
export default function Layout({ children }) {
return (
<Suspense fallback={null}>
<SessionProvider>{children}</SessionProvider>
</Suspense>
)
}
2. Build показва λ вместо ◐
Маршрутът се компилира като пълно SSR. Причина: има dynamic API някъде извън Suspense.
Решение: Стартирайте next build --debug-prerender за подробен stack trace, който посочва точния компонент и API. Този флаг е спасил часове от живота ми.
3. Suspense fallback не се показва в dev
В next dev PPR не се симулира пълно — за реално тестване винаги изпълнявайте next build && next start. Ще си спестите много обърквания.
4. CVE-2025-29927 (middleware bypass)
Версии преди 15.2.3 имат уязвимост, при която x-middleware-subrequest header може да заобиколи middleware. Винаги проверявайте автентикацията и в самия Server Component, не само в middleware — middleware не е граница за сигурност.
Кога да използвате PPR (и кога не)
| Сценарий | PPR подходящ? |
|---|---|
| Marketing страница с user widget в header | ✓ Да |
| E-commerce продукт с inventory | ✓ Да |
| Блог с коментари в реално време | ✓ Да |
| Dashboard, който е 100% персонализиран | ✗ По-добре пълен SSR |
| Чисто статичен landing page | ✗ По-добре SSG |
| API маршрути / Route Handlers | ✗ Не е приложимо |
FAQ — Често задавани въпроси
Различава ли се PPR от ISR?
Да. ISR кешира цялата страница и я ревалидира на интервали — не може да съдържа персонализирано съдържание. PPR кешира само статичния shell, а динамичните секции (обвити в Suspense) се изпълняват на всяка заявка. Те всъщност са комплементарни — можете да комбинирате PPR с "use cache" и cacheLife.
Production-ready ли е PPR в Next.js 15?
Режимът 'incremental' е стабилен и се използва в продукция от Vercel и доста големи компании. Глобалното ppr: true остава експериментално. Пълна стабилизация се очаква в Next.js 16.
Работи ли PPR на self-hosted сървъри (без Vercel)?
Да. PPR работи на всеки Node.js сървър, включително Docker, AWS, Cloudflare Workers (с adapter). Стриймингът използва стандартен HTTP chunked transfer encoding. За CDN кеширане на static shell-а може да е нужно тунинг на cache headers — но нищо екстремно.
Как PPR взаимодейства с Server Actions?
Server Actions работят независимо от стратегията на рендиране — те са POST endpoints, изпълнявани при mutation. След action, можете да извикате revalidatePath() или revalidateTag(), за да опресните static shell или кешираните секции.
Каква е разликата между Suspense в React 18 и в PPR?
В React 18 <Suspense> просто показваше fallback докато async компонент се зарежда — на runtime. В PPR <Suspense> играе двойна роля: на build time е граница между статично и динамично, а на runtime — стандартен loading boundary за стрийминг. Едно API, две роли.
Заключение
Partial Prerendering е най-значимата промяна в Next.js модела за рендиране от въвеждането на App Router насам. Той ви позволява да изграждате страници, които са едновременно светкавично бързи (CDN shell) и пълноценно динамични (персонализиран стрийминг) — без компромиси.
За да започнете: активирайте experimental.ppr = 'incremental', добавете experimental_ppr = true в избран маршрут, и обвийте dynamic секциите в <Suspense>. Стартирайте next build и потърсете символа ◐ в output-а — това е вашият първи PPR-enabled маршрут. Поздравления!
В следващите статии ще разгледаме как да комбинирате PPR с "use cache" директивата за още по-фин контрол на кеширането, и как да оптимизирате Suspense fallback UI за perfect Cumulative Layout Shift (CLS) score.