Product Detail Page (PDP)

Individual product pages with variants, media, and add-to-cart.

vercel.shop/products/classic-tee
S
DCart
DRecommendations

The Product Detail Page renders a single product with variant selection, an image gallery, buy actions, and recommendations. It is entirely server-rendered - variant selection happens through navigation, not client-side state.

Rendering strategy

The PDP runs under Next.js 16 Cache Components. Product data is fetched through "use cache" operations with cacheLife("max") and a product-{handle} tag, so the page renders from cache on every request and is only rebuilt when a Shopify webhook invalidates the matching tag — ISR-style freshness with no per-request fetch (see Shopify Integration for the webhook setup).

The only dynamic input is the ?variant= query parameter. Rather than block the whole page on that lookup, the route passes the searchParams promise — unawaited — down into ProductDetailSection. Anything that doesn't depend on the chosen variant (title, shared gallery images, uniformly-priced variants, the description, related products) renders straight from the cached product. Anything that does (the color-specific gallery slot, variant-specific price, option highlight, buy buttons) sits inside its own Suspense boundary so a ?variant= change streams the affected slot without skeletonizing the rest of the page.

Variant selection

Variants are selected via searchParams. Each option (color, size, etc.) renders as a <Link> pointing to the same product with Shopify's ?variant query parameter:

/products/classic-tee?variant=123456

The page receives searchParams as a promise, threads the variant ID through to computeSelection() on the server, and that function returns { selectedOptions, selectedVariant, colorImages } in one pass — matching the numeric variant ID to the full variant object. The result is threaded down as props with no client-side variant context or useState. This means:

  • Every variant selection is a navigation, giving you browser back/forward for free
  • URLs are shareable - opening a link loads the exact variant
  • The page is fully server-rendered with zero layout shift
  • Variant links use scroll={false} to prevent the page from jumping to the top on selection

The getVariantUrl() helper in lib/product.ts computes the target URL for each option. When a user selects a new option value, it finds the matching variant (or falls back to the first variant with that option) and returns the URL with the correct variant query parameter.

Unavailable options render as inert <span> elements instead of links.

The gallery is handled by a single ProductMedia component that adapts to screen size:

Desktop - A two-column grid of images with a lightbox for full-screen viewing. Videos are supported inline with autoplay.

Mobile - A horizontal snap-scroll carousel with dot indicators below. Swiping or tapping a dot navigates between images.

Both layouts receive pre-filtered images as props from the server.

Color-based image filtering

When a product has color variants, the gallery shows only images relevant to the selected color. Shared images render immediately, while the color-specific slot streams behind a small Suspense fallback so changing ?variant does not skeletonize the whole gallery. The getPartitionedImagesForSelectedColor() function in lib/product.ts:

  1. Identifies the color option via swatch data or option name
  2. Collects variant images assigned to each color
  3. Excludes images belonging to other colors
  4. Preserves shared/unassigned images
  5. Moves the selected variant's image to the front

If a product has only one color or no color option, all images are shown.

Buy section

The buy section includes stock status and two action buttons:

  • Buy with Shop - creates a Shopify checkout and redirects to it, styled per Shopify's brand guidelines
  • Add to Cart - adds the item optimistically to the cart drawer

A single BuyButtons component renders identically on both mobile and desktop. It receives the selectedVariant as a prop and is a client component for cart interactivity.

Below the product details, a RelatedProductsSection component fetches related products from Shopify's recommendation API. It renders inside a Suspense boundary with a skeleton grid fallback.

Related products display as a horizontally scrollable slider of product cards using the same ProductsSlider component as the homepage. On mobile, cards are ~7/12 viewport width and scroll full-bleed. On desktop, the slider shows 3-4+ columns with chevron navigation.

Layout

The page uses a single responsive layout wrapped in a Container:

  • Below the lg: breakpoint, content stacks vertically: carousel, buy buttons, product info
  • At lg: and up, a 12-column grid places the image gallery in 7 columns on the left and product info (title/price, option pickers, buy buttons, description) in 5 columns on the right
  • The product info column is sticky on desktop, staying visible while the gallery scrolls

Structured data

The page emits two JSON-LD schemas:

  • Product schema - name, description, images, brand, price range, availability, and variant count
  • Breadcrumb schema - Home → Product Title (JSON-LD only, no visible breadcrumb UI)

Key files

FilePurpose
app/products/[handle]/page.tsxRoute entry: metadata, rendering config, kicks off product + selection promises
components/product-detail/product-detail-section.tsxOrchestrates media, options, pricing, and buy buttons with per-section Suspense; emits JSON-LD
components/product-detail/product-media.tsxResponsive image gallery (mobile carousel, desktop grid + lightbox)
components/product-detail/buy-buttons.tsxBuy with Shop + add-to-cart client component
lib/product.tscomputeSelection, variant resolution, URL generation, color-image partitioning

The rest of the PDP — option pickers, price display, lightbox, schema, and so on — lives in components/product-detail/. Browse the directory when you need them.

Chat

Tip: You can open and close chat with I

0 / 1000