Skip to solution
hardFrontend

Partial Prerendering (PPR) in Next.js 15 — static shell + dynamic holes

147 views
01

Understand the problem

Question presented to candidate: "A product page is mostly static but shows a personalised cart badge. Historically you had to choose static or dynamic for the whole route. How does Partial Prerendering change that?"

What a strong answer should cover:

  • The problem it solves: rendering strategy was a per-route decision. One personalised element forced the entire page to be dynamic, losing the CDN-cached instant response.
  • PPR splits a single route: a static shell is prerendered at build time and served instantly from the edge, with holes where dynamic content will go.
  • The dynamic parts stream in afterwards, filling the holes — one HTTP response, progressively completed.
  • Suspense boundaries define the holes. Anything inside a boundary that uses dynamic APIs becomes a hole; everything else is prerendered into the shell.
  • The fallback you write becomes what is baked into the static shell, so it should be a real skeleton rather than a blank space.
  • It builds directly on streaming SSR and Suspense — the same mechanism, applied at the route level.
  • The user-visible benefit: near-instant first paint from cache with personalised content arriving moments later, rather than waiting for the slowest data before anything appears.
  • Status matters: PPR has been an experimental, opt-in Next.js feature rather than a default, so claiming it as standard is a mistake.

Clarifying questions expected:

  • "How much of the page is genuinely personalised?" — if most of it is, PPR buys little.
  • "Which Next.js version, and is the flag enabled?"

Code / implementation expected: Optional. Showing where the Suspense boundary goes is the substance.

nextjspprperformance
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for senior React interviews — assumes SSR and Suspense. Difficulty: Hard

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. A note on verification: PPR is a Next.js build-and-serve feature and **

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

Where the boundary goes decides what can be cached
Run Playground
import { Suspense, use, useState } from "react";

// This playground has no Next.js runtime, so PPR itself cannot run here. What
// it CAN show is the boundary decision that PPR turns into a caching decision:
// content outside a boundary is shell, content inside one is a hole.

const cache = new Map();
function slowData(key, ms, value) {
  if (!cache.has(key)) cache.set(key, new Promise((r) => setTimeout(() => r(value), ms)));
  return cache.get(key);
}

// Shared by every visitor — in a real PPR build this is prerendered into the
// static shell and served from the edge with no server work at all.
function ProductDetails() {
  return (
    <div style={{ background: "#eef7ee", padding: 12, borderRadius: 8 }}>
      <strong>Wireless Headphones</strong>
      <p style={{ margin: "4px 0", fontSize: 13 }}>
        Shared content — identical for everyone, so it belongs in the shell.
      </p>
    </div>
  );
}

// Personalised: in a real app this reads a cookie, so it cannot be prerendered.
function CartBadge() {
  const count = use(slowData("cart", 1400, 3));
  return (
    <span style={{ background: "#4f46e5", color: "white", padding: "3px 10px", borderRadius: 12, fontSize: 13 }}>
      {count} in your cart
    </span>
  );
}

// The fallback IS the cached first impression under PPR — so it should be a
// skeleton shaped like the real thing, not a blank space or a bare spinner.
function CartSkeleton() {
  return (
    <span style={{ background: "#e4e4e7", color: "transparent", padding: "3px 10px", borderRadius: 12, fontSize: 13 }}>
      0 in your cart
    </span>
  );
}

export default function App() {
  const [run, setRun] = useState(0);

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 500 }}>
      <p>
        <button onClick={() => { cache.clear(); setRun((r) => r + 1); }}>
          reload the page
        </button>
      </p>

      <div key={run} style={{ border: "1px solid #ddd", borderRadius: 8, padding: 12 }}>
        {/* SHELL — outside any boundary, so it is prerendered and instant */}
        <ProductDetails />

        <p style={{ margin: "10px 0 4px", fontSize: 13, color: "#666" }}>
          Below is the hole. Only this region waits.
        </p>

        {/* HOLE — the boundary marks the dynamic region */}
        <Suspense fallback={<CartSkeleton />}>
          <CartBadge />
        </Suspense>
      </div>

      <p style={{ color: "#666", fontSize: 13 }}>
        Press reload: the product details appear immediately while the badge
        shows its skeleton, then fills in. Under PPR the top half would have
        come from a CDN with no server render at all — and the skeleton is what
        ships in that cached HTML, which is why its shape matters.
      </p>
    </div>
  );
}
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 116 of 119 decoded in the React.js track. One more won't hurt.

Back to track