Skip to the solution
HardFrontend7 min read

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

148 views

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.

  • nextjs
  • ppr
  • performance

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

7 min read · 10 sections

Try it in the playground

Open any example in the playground to run and change it.

ReactWhere the boundary goes decides what can be cached
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>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 116 of 119 in React.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All React.js questions
Up nextHard

How does React Fiber work?

Continue