DEV Community

Ahmed Mahmoud
Ahmed Mahmoud

Posted on • Originally published at devya.dev

Partial Prerendering in Next.js: The Static Shell + Dynamic Stream Model

Headline: Partial Prerendering (PPR) in Next.js serves a static HTML shell from the CDN edge instantly, then streams Suspense-wrapped dynamic children from the origin in the same HTTP response. No full-page ISR staleness, no full-page origin latency. I shipped it on two production routes — here is the model.

Key takeaways

  • PPR serves a static HTML shell from the CDN edge, then streams dynamic Suspense children from the origin in the same response.
  • The static shell is built at build time — outside <Suspense> renders statically; inside renders dynamically per request.
  • PPR replaces the ISR vs. dynamic tradeoff for pages that are mostly static with isolated personalized sections.
  • No changes to Server Components or Suspense — just experimental.ppr: 'incremental' in config and export const experimental_ppr = true per route.
  • PPR and use cache are complementary: CDN delivery for the shell, origin memoization for dynamic islands.

What does PPR actually do?

PPR splits a page into two rendering phases within the same HTTP response. At build time, Next.js freezes everything that does not read dynamic request data into a static HTML shell on the CDN edge. At request time, the CDN delivers the shell at edge latency while the origin streams each <Suspense> boundary's content into the same response.

On a product page: navigation, title, and description arrive at CDN speed. The in-stock badge and personalized recommendations stream from the origin a fraction of a second later. The user sees a nearly-complete page immediately.

How is PPR different from ISR and streaming Suspense?

Strategy First byte Dynamic freshness Staleness
ISR (revalidate: N) CDN edge Whole page up to N seconds stale Full page
Dynamic rendering Origin 100% fresh; waits for slowest query None
Streaming Suspense (no PPR) Origin Fresh; TTFB includes origin latency None
PPR CDN edge Dynamic islands 100% fresh Static shell only

How do I enable PPR?

// next.config.ts
export default { experimental: { ppr: 'incremental' } };
Enter fullscreen mode Exit fullscreen mode
// app/products/[slug]/page.tsx
export const experimental_ppr = true;

export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  return (
    <main>
      <ProductHeader slug={slug} />          {/* static shell — CDN */}
      <Suspense fallback={<StockSkeleton />}>
        <StockAvailability slug={slug} />    {/* dynamic — origin */}
      </Suspense>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Anything outside <Suspense> must not read dynamic data. If it calls cookies(), the build fails with a clear error.

What belongs in the shell vs. inside Suspense?

Static shell: navigation, footer, static content, layout structure, images independent of the user's session.

Inside Suspense: user-specific content (cart, recommendations), real-time inventory or pricing, A/B cookie reads, any component calling cookies(), headers(), or searchParams.

How do PPR and use cache compose?

'use cache';
import { cacheLife, cacheTag } from 'next/cache';

export async function ProductDescription({ slug }: { slug: string }) {
  cacheLife('minutes');
  cacheTag(`product-desc-${slug}`);
  const desc = await db.product.findUnique({ where: { slug }, select: { description: true } });
  return <p>{desc?.description}</p>;
}
Enter fullscreen mode Exit fullscreen mode

PPR controls the CDN boundary. use cache memoizes origin renders inside Suspense. Both stack: CDN for the shell, component cache for the dynamic parts.

When should I use PPR vs. full dynamic rendering?

Use PPR when the page is mostly static with isolated dynamic sections — a product page with real-time inventory, a SaaS dashboard with a cached sidebar plus live metrics. If the entire page depends on per-request data, full dynamic rendering is simpler.

FAQ

Q: Does PPR require changes to Server Components or Suspense?
A: No. Only the config flag and per-route export. All existing patterns work unchanged.

Q: What happens to SEO with PPR?
A: The static shell is fully crawlable HTML from the CDN. Suspense-streamed content may or may not be indexed by crawlers. Keep title, description, and primary body text in the static shell.

Q: Can I use PPR and ISR on the same route?
A: No. A route opts into either PPR or ISR. PPR replaces ISR: static shell is the build-time snapshot, dynamic Suspense children are always fresh.

Q: Is PPR stable in Next.js 15 and 16?
A: Incremental opt-in, experimental through Next.js 16. Production-safe (Vercel uses it internally) but API may change before graduating to stable.

Q: How do I verify PPR is working?
A: Run next build. Routes with PPR show a split between static and dynamic segments in terminal output. In Chrome DevTools Network, the response arrives in chunks: static shell first, then the Suspense stream.


Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.

Top comments (0)