Skip to content

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>
  );
}
// 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: availability and price in the static shell will be stale if they come from cached data. Either accept bounded staleness (with a short revalidate), 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, correct sizes, 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_ready custom metric tracked

Next: 10.4 Search