3.6 — Partial Prerendering & personalization¶
Module 3 · Lesson 6 · 🔴 Advanced · ~40 min
What you'll learn¶
- How PPR gives you a static shell's TTFB with dynamic content's correctness
- Designing the shell/hole split for a commerce PDP
- Personalization strategies ranked by cost, with the right one per use case
- The correctness and security rules that make cached commerce pages safe
This lesson fixes Aurora's structural problem #1.
The dilemma PPR resolves¶
Every commerce team hits this and picks one. PPR refuses the choice: the page is prerendered up to the first dynamic boundary, the static shell is served instantly from the edge, and the dynamic parts stream in from the server as part of the same response.
Request
│
├─▶ Static shell served from cache at ~20ms
│ · header, nav, product images, name, description, specs, footer
│ · LCP element is IN this shell → LCP is a static-page LCP
│
└─▶ Dynamic holes stream into the same response
· member price (~90ms)
· store stock (~120ms)
· cart count (~40ms)
· recently viewed (~80ms)
The user sees a complete‑looking product page at 20 ms with prices filling in ~100 ms later. LCP behaves like a static page; correctness behaves like a dynamic one.
Enabling it¶
// next.config.ts
const config: NextConfig = {
experimental: {
ppr: 'incremental', // opt in per route rather than app-wide
},
};
Version note. PPR has been experimental across Next.js 15, and Next.js 16 folds the same capability into the Cache Components model (
cacheComponents: true+use cache). The architecture in this lesson — static shell, dynamic holes, strict separation — is stable and correct regardless of which API surface your version exposes. Check the docs for your version before adopting, and preferincrementalmode so you can migrate one route at a time.
Designing the shell/hole split¶
This is the design work. Get it wrong and you either leak personalized data into a shared cache (a breach) or you put so much in holes that PPR buys you nothing.
The classification¶
| Content | Static shell? | Why |
|---|---|---|
| Header, nav, footer | ✅ Shell | Same for everyone (cart count is a hole inside it) |
| Product images | ✅ Shell | Same for everyone — and it's the LCP element |
| Product name, description, specs | ✅ Shell | Catalog data |
| Breadcrumbs | ✅ Shell | Derived from category |
| Reviews list | ✅ Shell | Same for everyone; cache 1h |
| List price | ✅ Shell | Base price — same for everyone |
| Member/promo price | ❌ Hole | Depends on the user's segment |
| Stock at your store | ❌ Hole | Depends on geo/store selection |
| Cart count / mini‑cart | ❌ Hole | Per‑user |
| Recently viewed | ❌ Hole | Per‑user |
| "Ships by" date | ❌ Hole | Depends on postcode |
| Recommendations | ⚠️ Either | Static per product = cacheable; per‑user = hole |
| A/B variant content | ⚠️ Either | Prefer edge‑selected shell variants (8.5) |
The implementation¶
// app/p/[slug]/page.tsx
import { Suspense } from 'react';
export const experimental_ppr = true;
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
// Everything from here down that doesn't touch a dynamic API is prerendered
// into the static shell at build/revalidate time.
const product = await getProduct(slug); // cached data
if (!product) notFound();
return (
<main>
{/* ---------- STATIC SHELL ---------- */}
<Breadcrumbs category={product.category} />
<ProductGallery images={product.images} /> {/* LCP element */}
<h1>{product.name}</h1>
<ListPrice amount={product.listPrice} currency={product.currency} />
{/* ---------- DYNAMIC HOLES ---------- */}
<Suspense fallback={<PriceBandSkeleton />}>
<MemberPriceBand productId={product.id} />
</Suspense>
<Suspense fallback={<StockSkeleton />}>
<StoreAvailability productId={product.id} />
</Suspense>
<Suspense fallback={<div className="h-11" />}>
<AddToCartSection productId={product.id} variants={product.variants} />
</Suspense>
{/* ---------- STATIC AGAIN ---------- */}
<ProductDescription html={product.descriptionHtml} />
<SpecTable specs={product.specs} />
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={product.id} /> {/* cached data, streamed for TTFB */}
</Suspense>
</main>
);
}
// Each hole reads a dynamic API — that's what makes it a hole.
async function MemberPriceBand({ productId }: { productId: string }) {
const cookieStore = await cookies(); // ← dynamic
const segment = cookieStore.get('segment')?.value ?? 'guest';
const price = await getSegmentPrice(productId, segment);
if (!price.discount) return null;
return <PriceBand price={price} />;
}
async function StoreAvailability({ productId }: { productId: string }) {
const h = await headers(); // ← dynamic
const region = h.get('x-geo-region') ?? 'US-CA';
const stock = await getStoreStock(productId, region);
return <StockIndicator stock={stock} />;
}
The rule that keeps this safe:
Everything above a
<Suspense>boundary containing a dynamic read is shared by every user. If a personalized value influences the shell in any way — including conditionally rendering a shell element — you have leaked it into a shared cache.
Verifying the shell is not personalized¶
Do not rely on code review. Test it.
// tests/ppr-shell-isolation.spec.ts (Playwright)
import { test, expect } from '@playwright/test';
test('PDP static shell contains no user-specific data', async ({ browser }) => {
// User A: a logged-in member with a distinctive marker
const ctxA = await browser.newContext();
await ctxA.addCookies([
{ name: 'segment', value: 'vip-tier-3', domain: 'localhost', path: '/' },
{ name: 'session', value: 'user-a-session', domain: 'localhost', path: '/' },
]);
const pageA = await ctxA.newPage();
await pageA.goto('/p/wool-overshirt-navy');
await expect(pageA.getByTestId('member-price')).toBeVisible();
// User B: anonymous, fresh context, no cookies
const ctxB = await browser.newContext();
const pageB = await ctxB.newPage();
const responseB = await pageB.goto('/p/wool-overshirt-navy');
const htmlB = await responseB!.text();
// B must never see A's data — not in the DOM, not in the RSC payload
expect(htmlB).not.toContain('vip-tier-3');
expect(htmlB).not.toContain('user-a-session');
await expect(pageB.getByTestId('member-price')).toHaveCount(0);
});
test('shell is served from cache (fast) for both users', async ({ request }) => {
const res = await request.get('/p/wool-overshirt-navy');
// The prerendered shell should be a cache hit
expect(res.headers()['x-nextjs-cache']).toMatch(/HIT|STALE/);
});
Run this in CI on every PR that touches a PPR route. It is the single control that prevents the worst outcome in this module.
Personalization strategies, ranked¶
Not everything needs a dynamic hole. Pick the cheapest technique that's correct.
| # | Strategy | Cost | Correct for |
|---|---|---|---|
| 1 | Don't personalize | 0 | Most content. Question every personalization's measured lift |
| 2 | Client‑side from a local source | ~0 server | Recently viewed (localStorage), cart count (client store) |
| 3 | Static variants per segment | 1 render per segment | Currency, locale, market — small cardinality |
| 4 | Edge‑selected variant | ~5 ms at the edge | A/B tests, geo content, 2–5 variants |
| 5 | PPR dynamic hole | 1 partial render | Member price, store stock, ship‑by date |
| 6 | Client fetch after load | 1 client round trip | Below‑the‑fold personalization, "for you" rails |
| 7 | Fully dynamic page | Full render | Cart, checkout, account |
Strategy 2 — client‑side from local state¶
The cart count doesn't need a server round trip. It's in the client store already:
// components/cart-count.tsx
'use client';
import { useCartCount } from '@/lib/cart-store';
export function CartCount() {
const count = useCartCount(); // hydrated from localStorage + synced
// Reserve the space so the header never shifts
return (
<span className="inline-flex min-w-[1.5rem] justify-center tabular-nums" aria-live="polite">
{count > 0 ? count : ''}
</span>
);
}
This keeps the header entirely in the static shell. The alternative — a dynamic hole for the cart badge in the layout — would put a hole on every page in the app.
Strategy 3 — static variants per segment¶
When cardinality is small, render N static versions instead of one dynamic one:
/p/wool-overshirt-navy → US / USD (cached)
/uk/p/wool-overshirt-navy → UK / GBP (cached)
/de/p/wool-overshirt-navy → DE / EUR (cached)
40 markets × top 20K products = 800K pages. Too many to prebuild all, but ISR with
dynamicParams handles it, and each variant is fully cacheable. This is much better than one
dynamic page that reads a currency cookie.
Strategy 4 — edge‑selected variants¶
// middleware.ts (or proxy.ts on Next.js 16)
import { NextRequest, NextResponse } from 'next/server';
export function middleware(req: NextRequest) {
const country = req.headers.get('x-vercel-ip-country') ?? 'US';
const market = MARKET_BY_COUNTRY[country] ?? 'us';
// Rewrite to a cacheable per-market path; the CDN caches each variant
const url = req.nextUrl.clone();
url.pathname = `/${market}${url.pathname}`;
const res = NextResponse.rewrite(url);
// Tell the CDN this response varies by market, with LOW cardinality
res.headers.set('Vary', 'x-market');
return res;
}
export const config = {
// Keep the matcher tight — middleware runs on every matching request
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
Strategy 6 — client fetch for below‑fold personalization¶
For a "recommended for you" rail 2,000 px down the page, a dynamic hole is overkill — it holds the response open for content nobody has scrolled to.
'use client';
import useSWR from 'swr';
import { useInView } from 'react-intersection-observer';
export function PersonalizedRail({ productId }: { productId: string }) {
const { ref, inView } = useInView({ rootMargin: '400px', triggerOnce: true });
// Only fetch when the rail is near the viewport
const { data } = useSWR(inView ? `/api/recs?product=${productId}` : null, fetcher);
return (
<section ref={ref} className="min-h-[380px]"> {/* reserved space → no CLS */}
{data ? <RecRail items={data.items} /> : <RailSkeleton />}
</section>
);
}
Common PPR mistakes¶
Mistake 1 — dynamic read above the boundary¶
// ❌ cookies() at the top of the component makes the ENTIRE page dynamic.
// PPR is silently doing nothing.
export default async function ProductPage({ params }) {
const cookieStore = await cookies(); // 💀 kills the shell
const segment = cookieStore.get('segment')?.value;
const product = await getProduct((await params).slug);
return (
<>
<ProductGallery images={product.images} />
<Suspense><MemberPrice segment={segment} /></Suspense>
</>
);
}
// ✅ Push the read INSIDE the boundary
export default async function ProductPage({ params }) {
const product = await getProduct((await params).slug);
return (
<>
<ProductGallery images={product.images} />
<Suspense fallback={<PriceSkeleton />}>
<MemberPrice productId={product.id} /> {/* reads cookies() internally */}
</Suspense>
</>
);
}
Detect it: check the build output. If your PPR route shows ƒ (Dynamic) instead of
◐ (Partial Prerender), a dynamic read escaped the boundary.
Mistake 2 — holes that shift layout¶
The price band arriving 100 ms late and pushing the Add‑to‑Cart button down is CLS you created.
// ✅ Fallback occupies exactly the space the resolved content will
function PriceBandSkeleton() {
return <div className="h-[28px] w-[180px] rounded bg-neutral-100 animate-pulse" />;
}
// And when there's no discount, render nothing at the SAME reserved height,
// or design the layout so the band's presence/absence doesn't move anything
// (e.g. absolutely positioned, or a fixed-height container).
Mistake 3 — too many holes¶
Every hole is a Suspense boundary, a separate flush, and a piece of per‑request work. Eight holes on a PDP means the page assembles visibly and you've lost most of PPR's benefit.
Target: 2–4 holes per page. Combine related dynamic data into one hole:
// ✅ One hole, one fetch, all the per-user commerce state
<Suspense fallback={<CommerceStateSkeleton />}>
<CommerceState productId={product.id} /> {/* price + stock + ship-by in one call */}
</Suspense>
Mistake 4 — assuming the shell updates when data changes¶
The shell is cached like any static page. A price change updates the hole immediately but the shell's list price stays stale until revalidation. Wire both:
revalidateTag(`product-${id}`); // Data Cache (inputs)
revalidatePath(`/p/${slug}`); // Full Route Cache (shell)
await purgeCdn(`product-${id}`); // CDN
Aurora's PDP after PPR¶
| Metric | Fully dynamic | PPR |
|---|---|---|
| TTFB p75 | 910 ms | 85 ms |
| LCP p75 | 4.6 s | 2.1 s |
| Price correctness | ✅ | ✅ |
| Stock correctness | ✅ | ✅ |
| CDN shell hit ratio | 4% | 96% |
| Origin compute | $34K/mo | $9.2K/mo |
| Holes per page | — | 3 |
The remaining 85 ms of TTFB is the shell lookup plus the first hole's initial flush. The three holes resolve at 90/120/140 ms, all well before the user finishes reading the product name.
Common mistakes¶
| Mistake | Cost |
|---|---|
| Dynamic read above the Suspense boundary | PPR silently disabled; route goes fully dynamic |
| Personalized data influencing the shell | 🚨 Cross‑user data leak |
| 8+ holes | Visible assembly; benefit lost |
| Holes that resize on resolve | Self‑inflicted CLS |
| Forgetting to invalidate the shell on data change | Stale list prices |
| Using a hole where client state would do (cart count) | A hole on every page in the app |
| Personalizing things nobody measured a lift from | All the cost, no benefit |
| No cross‑user isolation test | The one bug you cannot afford |
Lab 3.6 — Split your PDP¶
- Classify every section of your PDP as shell or hole using the table above. Be strict: if a section conditionally renders based on user data, it's a hole.
- Count the holes. More than 4? Combine them into a single per‑user data call.
- Implement with
experimental_pprin incremental mode on one route. - Verify the mode in the build output —
◐, notƒ. - Write the cross‑user isolation test and put it in CI. Do this before shipping, not after.
- Check CLS with holes resolving on a throttled connection.
- Audit your personalization: for each personalized element, find the A/B test that proved it lifts conversion. Delete the ones with no evidence — that's the cheapest optimization available.
Checklist¶
- Every dynamic read is inside a Suspense boundary
- Build output shows the intended PPR symbol for the route
- 2–4 holes per page maximum
- Fallbacks are dimensionally identical to resolved content
- Cross‑user shell isolation test in CI
- Cart count and recently‑viewed handled client‑side, not as holes
- Shell invalidation wired alongside data invalidation and CDN purge
- Each personalization has measured evidence it's worth its cost