Product detail page
Render a product detail page and product selection URLs in one SDK flow.
Product detail page
Render a full product detail page from commerce.product.detail, including product selection URLs. The helper returns a ProductDetailResult: successful responses include a server-shaped payload with variants, options, option value slugs/media, brand, categories, tags, images, videos, and the listing rollup; product-detail 404 responses include found: false with a reason for missing, unpublished, or feature-disabled cases.
Successful product payloads expose inventory without sentinel values: product.totalInventory is the tracked stock sum across non-unlimited variants, null when no variants are tracked, and product.hasUnlimitedVariant signals whether any variant is unlimited.
Next.js Server Component (SSG / ISR)
// app/products/[slug]/page.tsx
import { createClient, resolveProductSelection } from '@01.software/sdk'
import { notFound } from 'next/navigation'
export const revalidate = 60
export default async function ProductPage({
params,
}: {
params: { slug: string }
}) {
const client = createClient({
publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY!,
})
const result = await client.commerce.product.detail({ slug: params.slug })
if (!result.found) return notFound()
const detail = result.product
const selection = resolveProductSelection(detail, {})
return <ProductView detail={detail} selection={selection} />
}React Client Component
'use client'
import {
buildProductHref,
createProductSelectionCodec,
getProductSelectionImages,
resolveProductSelection,
} from '@01.software/sdk'
import { createQueryHooks } from '@01.software/sdk/query'
import { useClient } from '@/lib/sdk'
export function ProductDetail({
slug,
search,
}: {
slug: string
search: string
}) {
const client = useClient()
const query = createQueryHooks(client)
const { data: result, isLoading } = query.useProductDetailBySlug(slug)
if (isLoading) return <Skeleton />
if (!result?.found) return <NotAvailable reason={result?.reason} />
const detail = result.product
const codec = createProductSelectionCodec(detail)
const normalizedSelection = codec.parse(search)
const selection = resolveProductSelection(detail, normalizedSelection)
const selectionQuery = codec.stringify(normalizedSelection)
const images = getProductSelectionImages(selection)
return (
<ProductView
detail={detail}
selection={selection}
images={images}
selectionQuery={selectionQuery}
/>
)
}Selection URL contract
Use createProductSelectionCodec(detail) for round-tripping selection state. Complete selections use variant=<variantId>; partial selections use opt.<optionId>=<valueId>. Older opt.<optionSlug>=<valueSlug> URLs still parse during Stage 1, but option and value slugs are compatibility metadata rather than canonical identity.
Use IDs from detail.options[].id and detail.options[].values[].id when building new selection links. Slugs are display and compatibility metadata, not the source of truth for new outbound URLs.
const codec = createProductSelectionCodec(detail)
const normalized = codec.parse('?opt.option-color=color-black')
const query = codec.stringify(normalized)Slug-only URLs such as ?black or ?color=black are rejected because option titles are not stable identifiers.
For listing cards, use buildProductHref(product, group, options) to compose
detail links with the same ID-based query contract.
On the detail page, parse that query with createProductSelectionCodec(detail)
or pass it to resolveProductSelection(detail, { search }). Selection media is
resolved in the same order as the selection itself: selected variant, selected
option value, partial matching variant, then listing/product fallback.
const href = buildProductHref(product, group, {
basePath: '/products',
detail,
})Option values in selection.availableValuesByOptionSlug include stock fields
for UI state: available is combinability, while availableForSale,
isUnlimited, and availableStock describe saleability.
Why { found: false, reason } and not an error?
The endpoint returns 404 with a code or reason field for not_found, not_published, or feature_disabled. The SDK maps those product-detail 404s to { found: false, reason } so consumer UIs can choose a standard 404, preview CTA, or feature-gating UI. Permission/auth errors, including 403 tenant mismatches, still throw typed SDK errors. To correlate backend triage, log client.lastRequestId against backend logs — the endpoint records the reason.
Cache invalidation
useProductDetail* cache invalidates automatically when any of 10 detail-relevant collections is mutated through SDK mutation hooks: products, product-variants, product-options, product-option-values, product-categories, product-tags, product-collections, brands, brand-logos, images.
For SSG/ISR and webhook-driven cache invalidation, tag product detail reads with
storefrontCacheResources.productDetail(scope, { slug }) before a slug-based
fetch, or { id } when the adapter already has the product ID. Use
productListing(scope) on detail pages only as a coarse fallback when your
invalidation pipeline cannot carry previous slugs yet; see
Storefront Cache Resources.
When to skip the helper
Use client.collections.from('products').find(...) (advanced escape hatch) when:
- You need bulk reads or custom filter combinations the helper does not expose.
- You need fields not in the helper response shape.
- You are reading on a write path under a transaction (use the server query builder).