> ## Documentation Index
> Fetch the complete documentation index at: https://quickbutik.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Catalog and products

> List and search products with React hooks, build product pages with ProductProvider and render a variant picker from headless components.

All of this runs under a [`<ShopkitProvider>`](/kit/react/setup). 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](/kit/concepts/currencies).

## Catalog hooks

```tsx theme={null}
import { useProducts, useProductSearch, useProduct, useCategories, useShop } from "@quickbutik/kit/react"

const { data, loading, error, refetch } = useProducts({ limit: 12 })
const { data: results } = useProductSearch({ search: q, sortBy: "price" })
const { data: product } = useProduct("prod_27")          // null when hidden or missing
const { data: categories } = useCategories({ root: true })
const { data: shop } = useShop()
```

| Hook | Calls | Returns `data` as |
| - | - | - |
| `useProducts(params)` | `products.list()`: the catalog in the merchant's order | `Page<Product>` |
| `useProductSearch(params)` | `products.search()`: filtered and sorted server-side | `Page<Product>` |
| `useProduct(id, options)` | `products.get()` | `Product \| null` |
| `useCategories(params)` | `categories.list()` | `Page<Category>` |
| `useShop()` | `shop.get()`: name, logo, brand colour | `Shop` |

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](/kit/concepts/campaign-storefronts), 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.

<Note>
  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](/kit/react/setup#using-your-own-data-layer).
</Note>

### A product grid

```tsx theme={null}
"use client"
import { formatMoney } from "@quickbutik/kit"
import { ProductImage, useProducts } from "@quickbutik/kit/react"

export function ProductGrid() {
  const { data, loading, error } = useProducts({ limit: 12 })

  if (loading && !data) return <p>Loading…</p>
  if (error) return <p role="alert">Could not load products.</p>

  return (
    <ul className="grid">
      {data?.data.map((product) => (
        <li key={product.id}>
          <a href={`/products/${product.slug ?? product.id}`}>
            <ProductImage product={product} width={400} height={400} />
            <h3>{product.name}</h3>
            <span>{product.price === null ? "" : formatMoney(product.price, product.currency ?? "SEK", { locale: "sv-SE" })}</span>
          </a>
        </li>
      ))}
    </ul>
  )
}
```

<Tip>
  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.
</Tip>

### 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.

```tsx theme={null}
"use client"
import { useState } from "react"
import { useProductSearch } from "@quickbutik/kit/react"

export function SearchBox() {
  const [q, setQ] = useState("")
  const { data, loading } = useProductSearch({ search: q, limit: 10 }, { enabled: q.length > 1 })

  return (
    <>
      <input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search" />
      {loading && <span>Searching…</span>}
      <ul>{data?.data.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
    </>
  )
}
```

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](/kit/reference/products).

## `<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:

```tsx theme={null}
<ProductProvider product={product}>            {/* already resolved            */}
<ProductProvider product={promiseForProduct}>  {/* a promise, unwrapped with use() */}
<ProductProvider slug="cotton-tee">            {/* looks it up itself           */}
<ProductProvider id="prod_27">                 {/* one request                  */}
```

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.

```tsx theme={null}
import { Suspense } from "react"
import { ProductProvider, SEO } from "@quickbutik/kit/react"

export default async function ProductPage({ params }) {
  const { slug } = await params
  const shopkit = await readOnlyShopkit()

  return (
    <Suspense fallback={<ProductSkeleton />}>
      <ProductProvider
        product={shopkit.products.getBySlug(slug)}   // not awaited
        notFound={<Missing />}
      >
        <SEO />
        <ProductView />
      </ProductProvider>
    </Suspense>
  )
}
```

<AccordionGroup>
  <Accordion title="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}`.
  </Accordion>

  <Accordion title="slug and id need a ShopkitProvider">
    The lookup forms need a client from a `<ShopkitProvider>` above. A resolved `product` needs none.
  </Accordion>

  <Accordion title="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](/kit/reference/products).
  </Accordion>

  <Accordion title="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).
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

<ParamField path="initialVariantId" type="number">
  Preselect a variant, for `?variant=` deep links.
</ParamField>

<ParamField path="selectFirstAvailable" type="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.
</ParamField>

<ParamField path="currency" type="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.
</ParamField>

<ParamField path="onVariantChange" type="(variant) => void">
  Called when the selection pins a different variant.
</ParamField>

<ParamField path="notFound" type="ReactNode">
  Rendered when the lookup resolves to `null`.
</ParamField>

### Product hooks

Read the state anywhere below the provider:

```tsx theme={null}
useProductState()          // everything (see below)
useOptionalProductState()  // the same, or null outside a provider
useProductOptions()        // VariantOptionGroupState[]
useProductOption("Color")  // one group by id or name, or null
useSelectedVariant()       // ProductVariant | null
useProductPrice()          // { amount, compareAtAmount, min, max, isRange, currency }
useProductAddToCart()      // { addToCart, canAddToCart, pending, error, missingOptions,
                           //   buyNow, buying, buyNowError }
```

`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

<CardGroup cols={1}>
  <Card title="Availability is per value, given the other groups" icon="table-cells">
    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.
  </Card>

  <Card title="A contradicting click clears, it does not refuse" icon="rotate">
    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.
  </Card>

  <Card title="Available means it exists and is not hidden, not in stock" icon="boxes-stacked">
    `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`.
  </Card>
</CardGroup>

<Warning>
  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.
</Warning>

## A complete variant picker

Assembled entirely from headless pieces; every element and class name is yours.

```tsx theme={null}
"use client"
import { formatMoney } from "@quickbutik/kit"
import {
  ProductImage,
  ProductOptionValues,
  ProductPrice,
  useProductAddToCart,
  useProductState,
} from "@quickbutik/kit/react"

export function ProductView() {
  const { product } = useProductState()
  return (
    <div className="detail">
      <ProductImage width={720} height={720} priority />
      <div>
        <h1>{product.name}</h1>
        <Price />
        <OptionGroups />
        <BuyButtons />
      </div>
    </div>
  )
}

function Price() {
  return (
    <ProductPrice>
      {({ amount, compareAtAmount, min, isRange, currency = "SEK" }) => (
        <p className="price">
          {amount !== null
            ? formatMoney(amount, currency)
            : isRange && min !== null
              ? `from ${formatMoney(min, currency)}`
              : min !== null
                ? formatMoney(min, currency)
                : ""}
          {amount !== null && compareAtAmount !== null && compareAtAmount > amount && (
            <s>{formatMoney(compareAtAmount, currency)}</s>
          )}
        </p>
      )}
    </ProductPrice>
  )
}

function OptionGroups() {
  const { options, select } = useProductState()
  return (
    <>
      {options.map((group) => (
        <fieldset key={group.id}>
          <legend>{group.name}</legend>
          <ProductOptionValues option={group.id}>
            {(value) => (
              <button
                key={value.id}
                type="button"
                aria-pressed={value.selected}
                aria-disabled={!value.available}
                data-unavailable={!value.available}
                onClick={() => select(group.id, value.id)}
              >
                {value.name}
              </button>
            )}
          </ProductOptionValues>
        </fieldset>
      ))}
    </>
  )
}

function BuyButtons() {
  const { addToCart, canAddToCart, pending, error, missingOptions } = useProductAddToCart()
  return (
    <>
      <button type="button" disabled={!canAddToCart} onClick={() => addToCart()}>
        {pending > 0
          ? "Adding…"
          : missingOptions.length
            ? `Select ${missingOptions[0].name}`
            : "Add to cart"}
      </button>
      {error && <p role="alert">{error.message}</p>}
    </>
  )
}
```

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.

```tsx theme={null}
<ProductOptions>{(groups) => groups.map((g) => <MyGroup key={g.id} group={g} />)}</ProductOptions>

<ProductOptionValues option="Color">
  {(value) => <ColourSwatch key={value.id} value={value} />}
</ProductOptionValues>

<ProductOptionGroup option="Size" fallback={null}>{(group) => <SizeRow group={group} />}</ProductOptionGroup>

<SelectedVariant fallback={<p>Select a size to continue</p>}>{(v) => <Sku sku={v.sku} />}</SelectedVariant>

<ProductPrice>{({ amount, min, isRange, currency }) => <MyPrice />}</ProductPrice>

<AddToCart>
  {({ addToCart, canAddToCart, missingOptions }) => (
    <button onClick={() => addToCart(1)} disabled={!canAddToCart}>
      {missingOptions.length ? `Välj ${missingOptions[0].name}` : "Lägg i varukorg"}
    </button>
  )}
</AddToCart>

<ProductConsumer>{(state) => <Anything state={state} />}</ProductConsumer>
```

`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](/kit/concepts/images).

```tsx theme={null}
// Inside a <ProductProvider>: the main image, no props needed
<ProductImage width={800} height={800} priority />

// In a card, from a listing
<ProductImage product={product} width={400} height={400} className="thumb" />

// A gallery
{product.images.map((image) => (
  <ProductImage key={image.id} image={image} product={product} width={120} />
))}

// By id, anywhere under <ShopkitProvider>: fetches, so put a <Suspense> above it
<ProductImage productId="prod_27" width={64} height={64} />
```

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.

| Prop | Does |
| - | - |
| `width` / `height` | CSS pixels. Drive the CDN resize, the `srcSet` and the box that prevents layout shift |
| `densities` | Pixel ratios for the `srcSet`. Default `[1, 2]`; `[]` disables it |
| `widths` | `w` descriptors instead, for an image that reflows. Pair with `sizes` |
| `transform` | `quality`, `format`, `fit`, `crop`, and the rest of `ImageTransform` |
| `alt` | Defaults to the image's `altText`, then the product name, then `""` |
| `fallback` | Rendered when there is no image. Defaults to nothing, never a broken `<img>` |
| `priority` | `loading="eager"` and `fetchPriority="high"`, for the LCP image. Everything else lazy-loads |

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:

```tsx theme={null}
function Hero() {
  const variant = useSelectedVariant()
  return <ProductImage imageId={variant?.imageId ?? undefined} width={720} height={720} priority />
}
```

### `useProductImage` for other renderers

For `next/image`, a CSS background or an `og:image`, use the hook twin:

```tsx theme={null}
import Image from "next/image"
import { useProductImage } from "@quickbutik/kit/react"

function Hero() {
  const { src, alt } = useProductImage({ width: 800 })
  if (!src) return <Placeholder />
  return <Image src={src} alt={alt} width={800} height={800} />
}
```

<Note>
  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.
</Note>

## 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](/kit/concepts/consent-and-analytics).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.