Lesson 14 of 81
A route used to have to pick a side: fully static and fast, or fully dynamic and fresh. Partial Prerendering lets one response be both — a prebuilt shell for the parts that don't change, and live holes for the parts that do.
13 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
Question 1 of 3
A teammate wraps an uncached `cookies()` read in a `<Suspense>` boundary, but the build still fails, complaining the route can't produce a static shell. The `cookies()` call happens inside the page component, above where the Suspense boundary is placed, not inside the child it wraps. What's the likely issue?
Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
Before Partial Prerendering (PPR), a route was either fully static (prerendered once, blazing fast, identical for everyone) or fully dynamic (rendered fresh per request, personalized, but slower because nothing ships until the slowest part finishes). PPR removes that all-or-nothing choice at the route level: a single route can prerender a static shell at build time — everything not gated behind runtime data or an uncached fetch — while leaving holes, wrapped in <Suspense>, that render at request time. Both arrive in one response: the shell instantly, the holes streamed in right after.
// app/shop/[slug]/page.tsx
import { Suspense } from "react";
import { ProductInfo } from "./product-info";
import { Cart } from "./cart";
export default function ProductPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
return (
<div>
<ProductInfo paramsPromise={params} />
<Suspense fallback={<p>Loading your cart…</p>}>
<Cart />
</Suspense>
</div>
);
}
// app/shop/[slug]/product-info.tsx
export async function ProductInfo({ paramsPromise }: { paramsPromise: Promise<{ slug: string }> }) {
"use cache";
const { slug } = await paramsPromise;
const product = await db.product.findUnique({ where: { slug } });
return <h1>{product.name}</h1>;
}
At build time, Next.js prerenders everything it can into a static shell: the layout, and anything cached with 'use cache' — here, the product info.
ProductInfo is cached with 'use cache', so its output joins the static shell alongside the layout and nav. Cart reads cookies() to know what's in this specific visitor's basket — genuinely per-request data — so it's wrapped in <Suspense>, and its fallback ships as part of that same shell. At request time, the shell is served instantly (straight from a CDN if one's in front of the app), and only Cart's real content has to be rendered and streamed in behind it. Nobody had to declare the whole /shop/[slug] route as "static" or "dynamic" — each piece made its own call.
cacheComponents: true is what gets you PPR in Next.js 16 — the old experimental.ppr config and experimental_ppr per-route export were removed entirely (there's a codemod to strip the leftover exports).'use cache' placement and <Suspense> boundaries, not as one setting for the whole route.cookies(), searchParams, or does an uncached fetch without a <Suspense> boundary somewhere above it, the build fails loudly rather than silently falling back to fully dynamic rendering for that route."How would you make a product page's shell instantly cacheable at the edge while still showing a signed-in visitor their own cart?" tests whether a candidate reaches for the shell-plus-holes model — caching what's shared, Suspense-wrapping what's per-request — instead of defaulting the whole route to one rendering mode because that's the only lever they remember from older Next.js versions.