Product Detail Page (PDP)
Individual product pages with variants, media, and add-to-cart.
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 uses a canary rendering strategy via unstable_instant with experimental.partialFallbacks:
partialFallbacks enables Partial Pre-Rendering (PPR), where the static shell of the page is served instantly and dynamic sections stream in. unstable_instant with prefetch: "runtime" tells Next.js to prefetch the dynamic data at request time rather than statically. The samples array provides representative parameters so the framework can pre-render the static shell at build time.
Variant selection
Variants are selected via searchParams. Each option (color, size, etc.) renders as a <Link> pointing to the same product with a ?variantId query parameter:
The page component receives searchParams as a promise and extracts the variantId:
On the server, computeInitialSelectedOptions() matches the numeric variant ID to the full variant object and extracts its selected options. These are threaded down as props - no client-side variant context or useState is involved. 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 variants.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 variantId.
Unavailable options render as inert <span> elements instead of links.
Image gallery
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. The getImagesForSelectedColor() function in variants.ts:
- Identifies the color option via swatch data or option name
- Collects variant images assigned to each color
- Excludes images belonging to other colors
- Preserves shared/unassigned images
- 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.
Recommendations
Below the product details, a Recommendations component fetches related products from Shopify's recommendation API. It renders inside a Suspense boundary with a skeleton grid fallback.
Recommendations display as a horizontally scrollable carousel of product cards using the same ProductsCarousel component as the homepage. On mobile, cards are ~7/12 viewport width and scroll full-bleed. On desktop, the carousel 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
| File | Purpose |
|---|---|
components/product/pdp/product-detail-page.tsx | Server component entry point, computes variant state and threads props |
components/product/pdp/variants.ts | Variant resolution, URL generation, image filtering |
components/product/pdp/color-picker.tsx | Color swatch Links with variant images |
components/product/pdp/option-picker.tsx | Text option Links (size, material, etc.) |
components/product/pdp/product-info.tsx | Header, options, and description composition |
components/product/pdp/product-media.tsx | Responsive image gallery (carousel on mobile, grid with lightbox on desktop) |
components/product/pdp/buy-buttons.tsx | Buy with Shop + add to cart buttons |
components/product/pdp/shop-logo.tsx | Shop wordmark SVG for the Buy with Shop button |
app/products/[handle]/page.tsx | Base product route |
app/products/[handle]/[variantId]/page.tsx | Variant-specific route |
next.config.ts | ?variantId → route segment rewrite |