<ShopkitProvider>. Prices are integers in minor units (24900 is 249,00 kr); format them with formatMoney from @quickbutik/kit and the currency the response states (product.currency), never a constant: a shopper can browse in another currency.
Catalog hooks
Each takes an options object with
initialData (and enabled on useProducts, useProductSearch and useCategories) and returns the same AsyncState<T>: { data, loading, error, refetch }. The three product hooks also take storefrontId (a parameter on useProducts / useProductSearch, an option on useProduct) to price one read for a campaign storefront, or null for none.
The product hooks follow the client’s currency and refetch when it changes (useCurrency().setCurrency()). They also take currency the same way as storefrontId, to read one request in another currency, or null for the shop’s own.
These hooks are intentionally not a cache. The fetch is aborted on unmount and on every re-run, and a response from a superseded run is discarded. Pass
initialData from a server render, or use your own data layer.A product grid
Search as you type
Every keystroke aborts the request before it, and only the newest response can render, so no debounce library is needed. The parameters are all primitives, so a fresh object literal on every render does not re-fetch.<ProductProvider>
<ProductProvider> holds the variant selection for one product and derives everything from it: option groups and their availability, the selected variant, the price and the add-to-cart guard. It renders nothing of its own.
Four ways to get the product in
In precedence order:use(), and the nearest <Suspense> shows the fallback while the shell streams.
React 18
React 18
slug, id and a promise all need React 19’s use(). On React 18 the provider throws a clear error; resolve the product yourself and pass product={product}.slug and id need a ShopkitProvider
slug and id need a ShopkitProvider
The lookup forms need a client from a
<ShopkitProvider> above. A resolved product needs none.getBySlug walks pages
getBySlug walks pages
The Storefront API has no slug filter, so a slug lookup pages through the catalog. Fine for a small shop; on a large one cache per slug or build a slug-to-id map at deploy time. See products reference.
Lookups are cached per client
Lookups are cached per client
Lookup promises are cached per client and slug or id. That is a correctness requirement:
use() suspends on the promise it is handed, and a fresh promise every render would suspend forever. Call clearProductCache(client, lookup?) from @quickbutik/kit after a revalidation in a long-lived browser session. If you pass your own promise, create it once (in the server component or a memo).Soft 404s
Soft 404s
Inside
<Suspense>, the response has already started streaming with a 200 by the time you know the product is missing. Render <SEO noIndex /> in the notFound branch, or await the lookup and call your framework’s notFound(), giving up streaming for that route.number
Preselect a variant, for
?variant= deep links.boolean
default:"false"
Start on the first non-hidden variant. Off by default: an empty start shows a price range, which is honest for a product whose variants differ in price.
string
A formatting fallback, used only when the product does not state its own
product.currency (a platform older than the field). useProductPrice().currency is the product’s own currency.(variant) => void
Called when the selection pins a different variant.
ReactNode
Rendered when the lookup resolves to
null.Product hooks
Read the state anywhere below the provider:useProductState() returns product, options, selection, selectedVariant, isComplete, hasOptions, price, currency and the actions select(optionId, valueId), clear(optionId), reset() and selectVariant(variantId).
useProductAddToCart() exists because every storefront otherwise rewrites the same guard: a product with options must not be addable until one variant is pinned, and the cart needs the variant id rather than the product id. missingOptions powers a “Select a size” prompt.
How the variant matrix behaves
Availability is per value, given the other groups
If Red exists only in L and XL, picking Red reports XXL unavailable, and picking XXL first reports Red unavailable. A value is evaluated with its own group excluded, so non-rectangular matrices work.
A contradicting click clears, it does not refuse
Picking Red while XXL is selected gives you Red with the size to re-pick. The just-clicked group is never cleared, so the shopper’s latest intent survives.
Available means it exists and is not hidden, not in stock
stock.stock is null for shops that do not track inventory and for preorder items, so the matrix ignores stock. To dim sold-out values, read stock off the variants listed in each value’s variantIds.A complete variant picker
Assembled entirely from headless pieces; every element and class name is yours.Headless components
Each hook has a component twin that renders only what itschildren function returns. Reach for the hook inside your own component; reach for these when you want the state inline.
ProductOptionGroup and ProductOptionValues render their fallback when the product has no such group, so a component written for “Color” degrades quietly on a product that has none.
Product images
<ProductImage /> renders one <img> with the CDN URL resolved, a srcSet, lazy loading and alt text. It exists because image.path is a bare storage filename, not a URL; rendering it directly 404s. See Images.
image, an explicit product, the surrounding <ProductProvider>, then a lookup by productId or slug. Which image: index (default 0) or imageId. Images whose file is still processing are skipped.
Every other
<img> attribute (className, style, sizes, onLoad, …) passes through.
Variant images
Swap the image when the shopper picks a variant that has its own:useProductImage for other renderers
For next/image, a CSS background or an og:image, use the hook twin:
With
next/image, add the shop’s image host (cdn.quickbutik.com) to images.remotePatterns. Never use a wildcard pattern: it turns /_next/image into an open image proxy.Analytics
With the default consent setup,<ProductProvider> reports view_item once per product, in the product’s own currency, and useProductSearch reports search once per term. Nothing is sent before the shopper consents. See Consent and analytics.