PicoDevs Logo
PicoDevs
Beyond Static vs. Dynamic: The Architecture of Next.js Partial Prerendering (PPR)
Back to Articles
Engineering9 min readAugust 23, 2026

Beyond Static vs. Dynamic: The Architecture of Next.js Partial Prerendering (PPR)

PicoDevs Studio

The Static/Dynamic Binary Is Dead — PPR Killed It

For a decade, every framework forced a per-route trade-off: prerender for instant TTFB and accept staleness, or render on-demand for freshness and pay the latency tax. Next.js Partial Prerendering (PPR) dissolves that binary by serving a cached static shell and streaming dynamic content into Suspense-defined holes within the same HTTP response. The result is a single render mode that behaves like static hosting for the critical shell and like SSR for personalized data — no client fetch waterfall, no dual infrastructure. This deep dive covers the two-phase render pipeline, the use cache directive, Next.js 15's cache-semantics reset, migration mechanics, and the failure modes the docs under-document. For implementation context, see the official Next.js Partial Prerendering documentation.

How PPR Works: One Page, Two Render Phases

Phase 1 — Build Time: Prewriting the Static Shell

At build, Next.js prerenders everything outside <Suspense> boundaries and stores the resulting artifact — full HTML plus the React Server Component (RSC) payload — in the edge cache. Crucially, the fallback prop of each Suspense boundary is serialized into that shell. The build output is therefore a complete, cacheable document containing designated placeholder regions:

  • Shell contents: layout, navigation, typography, static assets, and any subtree that resolves without request-time APIs.
  • Hole boundaries: serialized Suspense fallbacks (skeletons, spinners, empty states) that act as streaming insertion points.
  • Cache placement: shells are replicated to edge PoPs, giving single-digit-millisecond TTFB on cache hits — identical latency characteristics to fully static pages.

Phase 2 — Request Time: Hole Punching and Streaming

On a cache hit, the shell is flushed to the client immediately while the server concurrently renders the dynamic subtrees. As each completes, its RSC payload streams down the same connection and React swaps the fallback for real content — the same mechanism as streaming SSR, but with the head start of a prebuilt shell. Progressive enhancement applies: next/dynamic imports and client components hydrate selectively, so a slow personalized widget never blocks the interactive shell. The semantics rely on React's Suspense streaming and selective hydration model; PPR is best understood as Suspense boundaries promoted to architectural, cacheable contracts.

Cache Components and the use cache Directive

The cacheComponents experimental flag (formerly dynamicIO, exposed with PPR in the Next.js canary line) inverts the caching model: everything is static by default until explicitly proven dynamic. Under this regime, calling request-time APIs — cookies(), headers(), connection(), searchParams — opts a subtree into dynamic rendering, and build-time errors surface immediately if such calls leak into the shell. Caching becomes opt-in and declarative via the use cache directive:

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

export async function getProduct(id: string) {
  'use cache';              // memoizable cache scope
  cacheTag(`product-${id}`); // granular invalidation key
  cacheLife('hours');        // stale-while-revalidate profile
  return db.product.findById(id);
}

// On a CMS webhook or admin mutation:
import { revalidateTag } from 'next/cache';
revalidateTag('product-42');
  • Function- or file-scoped: use cache applies to the enclosing function or entire module; arguments must be serializable so entries can be keyed deterministically.
  • Cache profiles over magic numbers: cacheLife accepts semantic profiles (seconds, minutes, hours, days, weeks, max) mapping to stale/revalidate/expire windows, keeping cache policy reviewable in code.
  • Tag-based invalidation as the primary lever: revalidateTag surgically evicts entries without redeploying or path-level flushing — the correct primitive for event-driven data changes.

Why Next.js 15 Reset the Defaults

PPR only works when cache behavior is explicit, which is why Next.js 15 broke with prior defaults. Understanding this reset is prerequisite knowledge:

  • fetch requests are no longer cached by default (no-store unless configured) — opt-in caching via 'use cache' or fetch options.
  • GET Route Handlers are uncached by default, closing the surprise-caching footgun from the Pages Router era.
  • The Client Router Cache no longer caches by default on navigation, reducing stale-UI bug reports at the cost of more RSC fetches — a trade PPR's edge shells amortize.
  • ISR semantics are preserved, but under Cache Components ISR converges with use cache + cacheLife, unifying two previously parallel caching systems.

Migration Guide: Converting a Route to PPR

Enable incremental adoption so legacy routes keep their existing behavior while you convert selectively:

// next.config.ts
export default { experimental: { ppr: 'incremental' } };

// app/product/[id]/page.tsx — opt this route into PPR
export const experimental_ppr = true;

Then restructure the route around its true dynamic boundary:

import { Suspense } from 'react';

export default async function ProductPage({ params }) {
  const { id } = await params; // params/searchParams are async in Next 15

  return (
    <ProductHero id={id}>        {/* eligible for the static shell */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews id={id} />      {/* punched hole: streamed per request */}
      </Suspense>
    </ProductHero>
  );
}
  • Step 1: Audit every data read; classify as cacheable (product catalog, marketing copy) or request-scoped (cart, session, A/B variant).
  • Step 2: Push dynamic consumers as deep into the tree as possible — the smaller the hole, the larger the cached shell and the faster first paint.
  • Step 3: Wrap dynamic consumers in Suspense with designed, non-jarring fallbacks; fallbacks ship in the cached shell, so treat them as product surfaces.
  • Step 4: Replace implicit caching with 'use cache' + cacheTag so invalidation is provable from the diff.

The Failure Modes Nobody Documents

  • Shell leakage as a build feature: reading cookies() or headers() outside a Suspense boundary fails the build rather than silently degrading the whole route to dynamic. Treat these errors as architecture enforcement, not friction.
  • The searchParams trap: destructuring searchParams at the page level makes the entire page a hole. Read it inside the smallest component that needs it, wrapped in Suspense.
  • Fallback permanence and crawlers: bots that don't wait for streaming see skeletons in the initial HTML payload. Critical SEO content belongs in the shell; defer only data whose absence is acceptable pre-hydration.
  • Cache storage economics: every unique parameterized shell multiplies edge cache entries. Use generateStaticParams to cap shell cardinality and let genuinely unbounded combinations fall through to holes.
  • Client-boundary sprawl: marking components 'use client' to dodge cache discipline forfeits shell eligibility. Under Cache Components, client components render inside the cached shell only if their props serialize — keep them leaves, not containers.

Decision Matrix: PPR vs. ISR vs. Static vs. Client Fetch

  • Fully static: content with zero per-request variance (docs, landing pages) — PPR adds nothing; keep plain prerendering.
  • ISR: whole-page freshness on a schedule; correct when the entire page tolerates revalidate windows and personalization is absent.
  • PPR: mixed pages — fast-changing shells with localized personalization (dashboards, PDPs with live stock, logged-in nav). The dominant modern commerce and SaaS shape.
  • Pure client fetch: post-auth, high-velocity data where server render cost is the bottleneck (live collaboration cursors, tickers). PPR can still serve the shell.

PPR vs. the Field

Astro islands solve the same problem by shipping zero-JS static HTML with hydrated islands, but islands are a client hydration strategy — PPR's holes stream server-rendered content into a cached document, preserving SEO payloads and avoiding client-side data fetching entirely. Qwik's resumability attacks hydration cost rather than render latency; the approaches compose conceptually but PPR uniquely unifies cache infrastructure (edge CDN semantics, tag invalidation) with the React component model.

Bottom Line

PPR reframes rendering from a per-route mode selection into a per-subtree cache contract. Teams that internalize this — shell maximization, minimal dynamic holes, tag-driven invalidation — get static-page latency with dynamic-page fidelity, and a build pipeline that statically verifies the split. This is precisely how we architect rendering layers at Picodevs: if you're evaluating a migration, explore our edge-optimized application engineering services, or see production rendering architectures in our portfolio. More framework internals analysis is published weekly on our studio blog.

#Next.js#Partial Prerendering#PPR#Cache Components#React Server Components#Web Performance#Edge Computing
Share:
Strategic Deployment Matrix

Ready to Build
Something Extraordinary?

Partner with PicoDevs to architect, build, and deploy agentic AI platforms, cloud architectures, and high-performance web systems.

Capacity Available•Fast Turnaround•Production Guaranteed