Skip to main content
All of this runs under a <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

Use useProducts for the merchant’s own ordering, and useProductSearch for anything a shopper navigates: a category page, a sorted grid, a search box. search() applies visibility in the query, so its pages come back full; list() filters hidden products after paging and can return short pages.

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.
Search semantics (words are AND-ed, price bounds in minor units against the undiscounted price, direct category membership only, no facets) are in the products reference.

<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:
The promise form is the best default in a server-components framework: the server starts the fetch without awaiting it, the provider unwraps it with React 19’s use(), and the nearest <Suspense> shows the fallback while the shell streams.
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}.
The lookup forms need a client from a <ShopkitProvider> above. A resolved product needs none.
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.
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).
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.
Keep unavailable values clickable. Use aria-disabled, never disabled. A disabled value dead-ends a shopper who picked XXL first: every colour but one would be greyed out with no way back.

A complete variant picker

Assembled entirely from headless pieces; every element and class name is yours.
The option names (“Color”, “Size”, “Färg”) never appear in the markup: groups and values come from the product, so the same component serves a product with three groups, one, or none.

Headless components

Each hook has a component twin that renders only what its children 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.
Which product, in priority order: an explicit 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.