Product detail page
Render a product detail page and product selection URLs in one SDK flow.
Product detail page
Render a public-safe product detail page from commerce.product.detail, including product selection URLs. The browser helper returns a ProductDetailCatalogResult: successful responses include variants, options, option value slugs/media, brand, categories, tags, images, videos, and Shopify-shaped Product fields such as priceRange, availableForSale, and selectedOrFirstAvailableVariant; product-detail 404 responses include found: false with a reason for missing, unpublished, or feature-disabled cases. Direct detail allows products with status: 'published' or status: 'unlisted'; unlisted products stay out of listing helpers. Catalog detail omits raw stock quantities. Use createServerClient().commerce.product.detail() only for trusted server reads that need operational inventory fields.
Successful catalog payloads expose availability fields for rendering but omit raw stock, reservedStock, and product.totalInventory. Use stockSnapshot() or cart/checkout stock checks for live inventory decisions.
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.detailCatalog({ 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. not_published covers draft, archived, scheduled, and malformed lifecycle state. 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).