Skip to content

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

Fully static:   TTFB 20ms  ✅   Wrong price for members  ❌
Fully dynamic:  TTFB 910ms ❌   Correct price            ✅

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
  },
};
// app/p/[slug]/page.tsx
export const experimental_ppr = true;

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 prefer incremental mode 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

  1. 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.
  2. Count the holes. More than 4? Combine them into a single per‑user data call.
  3. Implement with experimental_ppr in incremental mode on one route.
  4. Verify the mode in the build output — , not ƒ.
  5. Write the cross‑user isolation test and put it in CI. Do this before shipping, not after.
  6. Check CLS with holes resolving on a throttled connection.
  7. 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

Next: 3.7 Middleware, edge & runtimes