Next.js Partial Prerendering
This guide is part of Next.js Performance, within Framework Performance. Most pages are mostly static with a few dynamic parts: a product page with a static description and dynamic price, stock and recommendations; an article with static content and a personalised "continue reading" rail; a homepage with static sections and a logged-in greeting. Traditionally, one dynamic part made the whole route dynamic, rendered per request, with TTFB tied to the slowest dynamic data.
Partial prerendering (PPR) splits a route into a static shell, prerendered at build time (or on revalidation) and served immediately from the edge, and dynamic "holes" wrapped in Suspense boundaries, rendered per request and streamed into the same response. The user gets the static shell — often including the LCP content — with static-site TTFB, and the dynamic parts arrive moments later in the same response.
Rapid Diagnosis
- Check route rendering mode in
next buildoutput: fully dynamic routes that are mostly static are PPR candidates. - Find what makes a route dynamic:
cookies(),headers(),searchParams, uncached fetches. - Measure TTFB for those routes; dynamic rendering often shows hundreds of milliseconds.
- Identify the LCP element: it should be in the static part.
Root Cause Analysis
1. One dynamic API taints the route. Reading cookies for a greeting makes the whole page dynamic.
2. Slow dynamic data blocks the response. TTFB waits for personalised or real-time data.
3. No Suspense boundaries. Nothing separates static from dynamic.
4. LCP inside dynamic sections. The shell arrives fast but the LCP content does not.
Step-by-Step Resolution
1. Enable partial prerendering
Enable PPR in the Next.js configuration (and per route where the version requires incremental adoption), following the documentation for your Next.js version.
2. Wrap dynamic parts in Suspense
// app/product/[id]/page.js
import { Suspense } from 'react';
export default async function Page({ params }) {
const product = await getProduct(params.id); // cached / static data
return (<>
<ProductHero product={product} /> {/* static shell, LCP */}
<Suspense fallback={<PriceSkeleton />}><LivePrice id={product.id} /></Suspense>
<Suspense fallback={<RecsSkeleton />}><Recommendations /></Suspense>
</>);
}
// LivePrice and Recommendations read cookies or uncached data, making them dynamic.
// trade-off: fallbacks are part of the static shell; size them to avoid CLS.
3. Keep dynamic APIs inside the holes
Call cookies(), headers() and uncached fetches only inside components wrapped in Suspense, never at the top of the page.
4. Keep LCP content static
Make sure the hero image and title come from cached or static data, so they are part of the prerendered shell.
Verification
The build output should mark the route as partially prerendered. In the browser, TTFB for the route should be near static levels, the HTML should contain the static shell immediately, and dynamic content should arrive later in the same response. LCP should come from the shell. Check CLS as holes fill in.
Worked Example: An Online Grocery Store
A grocery store's product pages read a delivery-area cookie to show local prices and availability, which made every product page dynamic. TTFB p75 was 720ms, LCP p75 2.6 seconds. With PPR, the product hero, images, description and nutrition table became the static shell (revalidated hourly), while price, stock and the delivery slot widget moved into Suspense holes. TTFB p75 fell to 110ms, LCP p75 to 1.7 seconds (the product image now started downloading from the shell), and prices still appeared within about 400ms. CLS stayed under 0.02 thanks to fixed-height price skeletons.
Designing Good Holes
Each hole should be small, self-contained and correctly sized. Group related dynamic data into one hole (price and stock together) rather than many tiny ones that each pop in separately. Keep personalisation that affects above-the-fold layout out of holes if possible, or design the fallback so the swap does not shift content. For content that is personalised but not urgent (recommendations), place holes below the fold. When a hole's data is slow, it delays only that hole — not the shell — which is the main advantage over fully dynamic rendering.
When PPR Does Not Help
PPR helps pages that are mostly static. Pages where most content is personalised (dashboards, inboxes) gain little, because the shell is nearly empty. Pages that are already fully static gain nothing. And PPR does not make dynamic data faster: if the price API takes 800ms, the price still appears after 800ms. Combine PPR with caching of dynamic data where possible.
Migrating a Dynamic Route to PPR
Start by listing what makes the route dynamic: search the page and its components for cookies(), headers(), searchParams, draftMode() and fetches with cache: 'no-store' or revalidate: 0. For each, decide whether the data truly varies per request or could be cached with revalidation. Move the genuinely per-request parts into small components wrapped in Suspense, with fallbacks sized like the final content. Then enable PPR for the route and check the build output: the route should be reported as partially prerendered. Finally, compare TTFB, LCP and CLS for the route in the field before and after, and confirm that the dynamic sections still show correct, personalised data.
Common Mistakes
- Dynamic APIs at the page level. The whole route stays dynamic.
- LCP in a hole. Fast TTFB, slow LCP.
- Unsized fallbacks. CLS when holes fill in.
- Too many tiny holes. Visual churn and overhead.
Edge Cases
Caching headers. The static shell is cached; dynamic content is per request — the response as a whole is not cacheable as a single unit by shared caches.
Search engines. Crawlers receive the full streamed response, including dynamic content.
Middleware. Middleware still runs per request; keep it fast.
Experimental status. Check your Next.js version's documentation for the current status and configuration of PPR.
FAQ
What is partial prerendering?
A Next.js rendering mode where a route's static parts are prerendered as a shell and dynamic parts are rendered per request and streamed into Suspense boundaries.
How is PPR different from ISR?
ISR regenerates entire static pages periodically. PPR mixes static and per-request content within one response.
Does PPR improve TTFB?
Yes, for routes that were dynamic: the shell is served like a static page.
Can I use PPR with cookies?
Yes, inside components wrapped in Suspense. Reading cookies outside a boundary makes the route fully dynamic.
Does PPR require edge hosting?
It works best when the shell is served from a CDN or edge; the dynamic parts are rendered by the server or function.
How do I avoid layout shifts?
Give fallbacks the same dimensions as the content they replace.
Is PPR stable?
Its status has evolved across Next.js versions; check the documentation for your version before relying on it in production.
Does PPR reduce client JavaScript?
Not directly. It changes how HTML is produced and delivered. Combine it with Server Components to keep client JavaScript small.
Can a page use PPR and ISR together?
Yes. The static shell can be revalidated on a schedule or on demand, while dynamic holes are rendered per request.
Do fallbacks count towards LCP?
Only if a fallback is the largest element painted. Keep the real LCP content in the shell so fallbacks are not candidates.
Related
- Streaming SSR with React Suspense — the streaming mechanism behind PPR.
- Edge compute & dynamic caching — similar ideas at the CDN.
- Fixing slow LCP with the Next.js Image component — making the shell's LCP fast.