10.3 — Product / PDP playbook¶
Module 10 · Lesson 3 · 🟡 Intermediate · ~35 min
The highest‑revenue‑influence page, 31% of sessions, and the page where every module in this course comes together.
Profile¶
| Share of sessions | ~31% |
| Cacheability | Content: very high. Price/stock: none |
| LCP element | The product image, always |
| Dominant risks | LCP image discovery, variant switching INP, reviews widget, third parties |
| Rendering strategy | PPR — static shell + 2–3 dynamic holes |
Budgets¶
| Metric | Target |
|---|---|
| LCP | ≤ 2.2 s |
| INP | ≤ 200 ms |
| CLS | ≤ 0.05 |
| TTFB | ≤ 400 ms |
| JS (gz) | ≤ 260 KB |
| Above‑fold images | ≤ 220 KB |
atc_ready |
≤ 2.5 s |
The full structure¶
// app/p/[slug]/page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
export const experimental_ppr = true;
export const revalidate = 3600;
export const dynamicParams = true;
export async function generateStaticParams() {
const top = await getTopProductsBySales({ limit: 20_000 });
return top.map((p) => ({ slug: p.slug }));
}
export async function generateMetadata({ params }) {
const { slug } = await params;
const product = await getProduct(slug); // deduped with the page's call via cache()
if (!product) return {};
return {
title: `${product.name} | Aurora Market`,
description: product.metaDescription,
openGraph: { images: [{ url: product.images[0].url, width: 1200, height: 1600 }] },
};
}
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const product = await getProduct(slug); // the ONLY blocking call
if (!product) notFound();
return (
<main>
<Breadcrumbs category={product.category} productName={product.name} />
<div className="grid lg:grid-cols-2 lg:gap-12">
{/* ─── STATIC SHELL: the LCP element lives here ─── */}
<ProductGallery images={product.images} productName={product.name} />
<div>
<h1 className="text-2xl font-medium">{product.name}</h1>
<p className="text-sm text-neutral-500">{product.brand}</p>
{/* List price is the same for everyone — static */}
<ListPrice amount={product.listPrice} currency={product.currency} />
{/* ─── DYNAMIC HOLE 1: everything per-user, in ONE call ─── */}
<Suspense fallback={<CommerceStateSkeleton />}>
<CommerceState productId={product.id} variants={product.variants} />
</Suspense>
{/* Static again */}
<ProductDescription html={product.descriptionHtml} />
<SpecTable specs={product.specs} />
<ShippingAndReturns policy={product.shippingPolicy} />
</div>
</div>
{/* ─── STREAMED, cached data ─── */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={product.id} />
</Suspense>
<Suspense fallback={<div className="min-h-[420px]" />}>
<Recommendations productId={product.id} />
</Suspense>
<ProductJsonLd product={product} />
</main>
);
}
The gallery — where LCP is won or lost¶
// components/product-gallery.tsx — Server Component
import Image from 'next/image';
export function ProductGallery({ images, productName }: Props) {
return (
<div>
{/* Main image: server-rendered, priority, in the initial HTML.
This is the single most important element on your site. */}
<div className="relative aspect-[3/4] overflow-hidden bg-neutral-100">
<Image
src={images[0].url}
alt={images[0].alt ?? productName}
fill
priority
sizes="(max-width: 1024px) 100vw, 50vw"
className="object-cover"
/>
</div>
{/* Thumbnails: server-rendered, lazy. The interactive layer is a client leaf. */}
<div className="mt-3 flex gap-2 overflow-x-auto snap-x">
{images.slice(0, 8).map((img, i) => (
<div key={img.url} className="relative aspect-square w-20 shrink-0 snap-start">
<Image
src={img.url}
alt=""
fill
sizes="80px"
loading={i === 0 ? 'eager' : 'lazy'}
className="object-cover"
/>
</div>
))}
</div>
{/* Client-only: swapping the main image, zoom, keyboard nav.
It hydrates ON TOP of the server-rendered image above. */}
<GalleryInteractivity images={images} />
</div>
);
}
The rule this encodes: the LCP image is rendered by the server, unconditionally, before any
JavaScript. Interactivity is layered on afterwards. Get this wrong — put the gallery inside
'use client' with a mounted check — and you add 500–2,000 ms to LCP
(2.1).
The commerce hole¶
// Everything per-user in ONE dynamic hole, ONE backend call
async function CommerceState({ productId, variants }: Props) {
const cookieStore = await cookies(); // ← makes this dynamic
const segment = cookieStore.get('segment')?.value ?? 'guest';
const region = (await headers()).get('x-geo-region') ?? 'US';
// One call for price, stock, and delivery estimate
const state = await getCommerceState(productId, { segment, region });
return (
<>
{state.promoPrice && <PromoPriceBand price={state.promoPrice} />}
<VariantSelector variants={variants} availability={state.availability} />
<DeliveryEstimate estimate={state.deliveryEstimate} />
<AddToCartForm productId={productId} />
</>
);
}
// The fallback must be EXACTLY the same height, or you've built CLS
function CommerceStateSkeleton() {
return (
<div className="space-y-4" aria-busy="true">
<div className="h-7 w-40 rounded bg-neutral-100" /> {/* promo band */}
<div className="h-24 rounded bg-neutral-100" /> {/* variants */}
<div className="h-5 w-56 rounded bg-neutral-100" /> {/* delivery */}
<div className="h-12 rounded-full bg-neutral-100" /> {/* add to cart */}
</div>
);
}
Problem 1 — Variant switching¶
The PDP's signature interaction. Target: under 150 ms to the swatch highlighting.
'use client';
import { useState, useTransition, useOptimistic } from 'react';
export function VariantSelector({ variants, availability, productId }: Props) {
const [selected, setSelected] = useState(variants[0].id);
const [isPending, startTransition] = useTransition();
const select = (variantId: string) => {
setSelected(variantId); // URGENT: swatch highlights this frame
startTransition(() => {
updateGalleryImage(variantId); // NON-URGENT
updatePriceAndStock(variantId); // NON-URGENT, may hit the network
history.replaceState(null, '', `?variant=${variantId}`);
});
};
return (
<div role="radiogroup" aria-label="Colour">
{variants.map((v) => (
<button
key={v.id}
role="radio"
aria-checked={v.id === selected}
disabled={!availability[v.id]?.inStock}
onClick={() => select(v.id)}
// Preload the variant's image on hover so the swap is instant
onMouseEnter={() => { const i = new Image(); i.fetchPriority = 'low'; i.src = v.imageUrl; }}
className={v.id === selected ? 'ring-2 ring-neutral-900' : 'ring-1 ring-neutral-200'}
>
<span className="sr-only">{v.name}</span>
<span style={{ background: v.swatch }} className="block h-9 w-9 rounded-full" />
</button>
))}
</div>
);
}
Do not remount the gallery on variant change. A key change forces a full remount, a new image
download with no cache benefit, and a visible flash.
Problem 2 — Reviews¶
Reviews are the classic PDP weight problem: a third‑party widget, 40–120 KB, that also causes CLS.
// ✅ Server-render the review content; skip the widget entirely
async function Reviews({ productId }: { productId: string }) {
const reviews = await getReviews(productId, { limit: 10, sort: 'helpful' });
if (!reviews.total) return null;
return (
<section id="reviews" className="mt-16">
<ReviewSummary average={reviews.average} total={reviews.total} distribution={reviews.distribution} />
<ul className="mt-6 space-y-6">
{reviews.items.map((r) => <ReviewItem key={r.id} review={r} />)}
</ul>
{reviews.total > 10 && (
// The full widget loads only if someone wants more
<LoadMoreReviews productId={productId} total={reviews.total} />
)}
</section>
);
}
Most review vendors have an API. Using it instead of the widget typically saves 60–120 KB and all of the widget's CLS, and gives you review content in your HTML for SEO — which the widget's JS‑rendered version doesn't.
Problem 3 — Structured data¶
Required for rich results, and easy to make expensive.
// Server Component — no client JS, no extra fetch
export function ProductJsonLd({ product }: { product: Product }) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
image: product.images.slice(0, 4).map((i) => i.url),
description: product.metaDescription,
sku: product.sku,
brand: { '@type': 'Brand', name: product.brand },
offers: {
'@type': 'Offer',
price: (product.listPrice / 100).toFixed(2),
priceCurrency: product.currency,
availability: 'https://schema.org/InStock',
url: `https://www.auroramarket.com/p/${product.slug}`,
},
aggregateRating: product.rating?.count
? { '@type': 'AggregateRating', ratingValue: product.rating.average, reviewCount: product.rating.count }
: undefined,
};
return (
<script
type="application/ld+json"
// Data is server-controlled; still escape to be safe
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c') }}
/>
);
}
Note the trade‑off:
availabilityandpricein the static shell will be stale if they come from cached data. Either accept bounded staleness (with a shortrevalidate), or omit precise availability from JSON‑LD and let the crawler read the page. Google tolerates reasonable staleness; customers do not, which is why the visible price is a dynamic hole.
Diagnosis order¶
PDP LCP > 2.2s?
├─ Is the product image in the initial HTML? → [2.1] Bug 1 — the #1 PDP bug
├─ Is `sizes` correct? → [2.1] Bug 2
├─ Is it `priority`? → [2.1] Bug 3
├─ Is it AVIF at q~72? → [2.1] Format
├─ Is the route dynamic (ƒ not ◐)? → [3.1], [3.6]
├─ Is TTFB > 400ms? → [6.4]
└─ Is a dynamic read above the Suspense boundary? → [3.6] Mistake 1
PDP INP > 200ms?
├─ Variant switch not in a transition? → [5.5]
├─ Gallery remounting on variant change? → remove the key change
├─ Reviews widget hydrating? → server-render reviews instead
├─ Cart context re-rendering the page? → [5.3]
└─ Interactions during hydration? → [4.4], [6.2] Section A
PDP CLS > 0.05?
├─ Commerce hole resizing on resolve? → match skeleton dimensions exactly
├─ Gallery without aspect-ratio? → aspect-[3/4] container
├─ Reviews widget injecting? → server-render
├─ Promo band appearing? → reserve the height
└─ Font swap? → [2.2]
Aurora's PDP, before and after¶
| Metric | Before | After |
|---|---|---|
| TTFB | 910 ms | 85 ms |
| LCP | 4.6 s | 1.9 s |
| INP | 320 ms | 142 ms |
| CLS | 0.26 | 0.01 |
| JS (gz) | 734 KB | 247 KB |
atc_ready |
4.4 s | 2.1 s |
| CDN shell hit ratio | 4% | 96% |
Which modules produced it:
| Module | Contribution |
|---|---|
| 2.1 Images | −1.4 s LCP |
| 2.3 Third parties | −570 ms TBT, −140 KB |
| 3.2 Boundaries | −272 KB |
| 3.3 Streaming | −640 ms TTFB |
| 3.6 PPR | −105 ms TTFB, cacheable shell |
| 4.3 Dependencies | −215 KB |
| 5.5 Concurrency | −178 ms INP |
| 6.3 CLS | −0.25 CLS |
Checklist¶
- PPR enabled; build output shows
◐notƒ - Product image server‑rendered,
priority, correctsizes, AVIF - Gallery interactivity layered on top, not gating the image
- All per‑user data in ONE dynamic hole with ONE backend call
- Hole fallback dimensionally identical to resolved content
- Cross‑user shell isolation test in CI (3.6)
- Variant selection: swatch urgent, everything else a transition
- Variant images preloaded on hover at low priority
- No gallery remount on variant change
- Reviews server‑rendered from an API, not a widget
- Recommendations streamed with reserved space
- JSON‑LD server‑rendered
-
atc_readycustom metric tracked
Next: 10.4 Search