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

# Troubleshooting

> Symptom, cause and fix for the problems headless Quickbutik storefronts run into most.

Start with the first section. A wrong key, a missing scope or a wrong `apiUrl` explains most broken setups, and `shop.get()` plus `products.list({ limit: 1 })` reproduces them in seconds.

## Keys and setup

| Symptom | Cause | Fix |
| - | - | - |
| `ShopkitConfigError: publishableKey must start with "qb_pk_"` | A `qb_pat_` token or an empty env var was used | Use a publishable key; never ship a personal access token. Check the env var exists where the code runs (server **and** client for `NEXT_PUBLIC_*`) |
| `ShopkitScopeError: … requires the "products:read" scope` | The client's `scopes` option omits it. The local check only knows the scopes you declare | Declare the key's real scopes, or leave `scopes` at its default |
| `403` with `required_scopes` | The key lacks the scope, for example a key from the API keys page's "Create public key" button (`storefront:read` only) | Use the key from **Custom storefront** in the admin. See [Publishable keys](/kit/publishable-keys) |
| `401` on every call | Key revoked or rotated, a wrong `apiUrl`, or a merchant endpoint called on the commerce host | Leave `apiUrl` unset; use `onUnauthorized` for rotation |
| `401` shaped `{"code":401,"error":"Unauthenticated"}` | The request went to the older `/v1` API | Use `/v2` paths only |
| `404` for `/v2/v2/products` | Should not happen with the kit (a trailing `/v2` on `apiUrl` is stripped); seen with hand-built URLs | Leave `apiUrl` unset, or build paths from `shopkit.apiUrl` |
| `Cannot find module '@quickbutik/kit'` | An `.npmrc` maps the `@quickbutik` scope to a private registry | Remove the mapping; the kit is on public npm |
| `ReferenceError: HTMLElement is not defined` | `@quickbutik/kit/elements` imported in server code (Astro frontmatter, SSR entry, tests in Node) | Import it only from browser code; the root entry is server-safe |

## Catalog

| Symptom | Cause | Fix |
| - | - | - |
| The storefront looks empty | Products are hidden until `visible: true` | Make them visible in the admin or the Merchant API |
| `400 "Invalid limit value…"` | `limit` is 0, negative or not a number | Use 1–200. Above 200 is silently capped |
| `list()` pages shorter than `limit` | Hidden products are filtered after paging | Use `products.search()` for anything a shopper navigates |
| `search()` returns products outside `minPrice`/`maxPrice`, or a price sort looks wrong | Bounds and sort use the undiscounted list price | Expected; filter the page again if bounds must be exact |
| Category page empty although the category has products | `categoryId` is direct membership only | Walk children with `categories.list({ parentId })` |
| Raw `fetch` with `?categoryId=12` returns 400 | The API expects `cat_12` | Send the prefixed id (the SDK prefixes numbers for you) |
| Every product looks sold out | `stock.stock === null` treated as 0 | `null` means not tracked, so purchasable |
| Product images all broken (`<img src="5c7b0e7e1802c.jpeg">`) | Rendering `image.path`, a bare filename | Render `image.url`, or use `<ProductImage />`, `<qb-product-image>` or `resolveProductImageUrl()` |
| `resolveProductImageUrl` returns `null` for an existing image | The file is still processing (`contentHash: "temp"`) | Wait, or pass `includePending: true` |
| `getBySlug` slow on a large catalog | It walks pages; there is no slug filter | Cache per slug, or build a slug → id map from `listAll()` at build time and use `get()` |
| Variant picker dead-ends | Unavailable values rendered `disabled` | Keep them clickable (`aria-disabled`); selecting one clears what contradicts it |
| `ProductProvider` throws about React 19 `use()` | `slug`, `id` or a promise on React 18 | Resolve the product yourself and pass `product` |
| `ProductProvider` suspends forever | A new promise on every render | Create the promise once (in the server component) or use `slug` / `id` |
| Money shows `29900 kr` | Minor units rendered raw | `formatMoney(amount, currency)`, or divide by 100 |

## Cart

| Symptom | Cause | Fix |
| - | - | - |
| `updateItem` / `removeItem` returns 404 | The product id was passed instead of the line id | Use `item.id` from the cart |
| First add after weeks fails with 404 | Stale cart id in a cookie | Use `cart.add()`, which recovers once automatically |
| Cart badge shows 0 then jumps | No server-read cart handed to the browser | Pass `initialCart` to `ShopkitProvider` (or `<qb-shop>.initialCart`) |
| Cart does not survive a reload | Storage degraded to memory (`runtime.persistent === false`): private mode, blocked cookies, a server without a cookie accessor | Supply `cookies` on the server; check `document.cookie` works |
| Server and browser see different carts | `httpOnly` cookies, or a different `storageKeyPrefix` / `domain` on each side | Do not set `httpOnly`; keep prefix and attributes identical |
| Badge shows 1 after every add inside an app builder's preview | Cookies are blocked in the preview iframe | Pass `cartId` explicitly or use `storage: "localStorage"` while previewing. See [Going live](/kit/concepts/going-live#building-inside-an-ai-app-builder-preview) |
| Badge and drawer disagree with your own cart code | A second client was created for the cart | Use `Quickbutik.cart`, `configure().cartStore` or `useCart()` |

## Checkout

| Symptom | Cause | Fix |
| - | - | - |
| `400 "Cannot hand off an empty cart to the checkout"` | No `cartId` passed and this client remembers no cart (a script, a server without cookies, another client, a preview iframe) | Pass `cartId: cart.id` from the cart you added to |
| `400 "successUrl must use https"` | `http` on a non-loopback host, or another scheme | Serve over https; `http` works only on `localhost` / loopback |
| `400 "successUrl is not a valid URL"` | A relative path passed to the SDK | Pass an absolute URL: `${origin}/success` |
| `ShopkitConfigError: cart … does not exist` | The `cartId` no longer exists | Pass the id of the cart you added to |
| Shopper returns to `/success/123` and gets a 404 | Thank-you page mounted at the `successUrl` path | Only the origin is used: mount `/success/[orderNumber]`, with a rewrite on static hosts |
| Thank-you page says "payment failed" for a real order | A `timeout` treated as a failure | `timeout` means still processing; only `failed` is terminal |
| "No checkout session to confirm" | The session cookie is missing: created where cookies were read-only, on another origin, or cleared | Start the checkout from a server action, route handler or the browser, on the storefront's origin |
| Checkout shows demo payment methods and creates no order | The shop has not activated Quickbutik Payments | Activate payments in the admin; no code change |
| `result.session` is `undefined` | The shop runs the legacy checkout (`checkout === "legacy"`) | Branch on `checkout`; `url` and `handoffId` are always there |
| Legacy shop: a failed payment shows as `no_attempt` | Legacy orders do not report `failed` | Expected; let the merchant's confirmation email be the source of truth |
| Hosted checkout says "We couldn't find 100200300A" | A URL built with the key's prefix instead of the numeric shop id | Use `checkout.start()`, or `hostedUrl({ sessionId, storeId: cart.storeId })` |
| `ShopkitConfigError: hostedUrl() needs the shop's numeric store id` | A fresh client has read no cart yet | Pass `storeId: cart.storeId`, or use `checkout.start()` |
| Checkout shows an old cart | A resumed session with a stale snapshot | `start()` syncs automatically; call `checkout.syncCart(sessionId)` when resuming yourself |
| `ShopkitConfigError: the platform has no checkout host configured for this shop` | No checkout URL for the shop | Contact Quickbutik support |
| Checkout does not open from an app builder's preview | `window.top.location` was used | Use `location.assign(url)` |

<Note>
  Legacy-checkout shops now return the shopper to `<successUrl origin>/success/<orderNumber>` and honour `backUrl`, exactly like checkout-v2. Older guidance that said the legacy handoff is terminal no longer applies.
</Note>

## Embedded checkout

| Symptom | Cause | Fix |
| - | - | - |
| Blank for 15 seconds, then a redirect; `qb:checkout-fallback` with `handshake-timeout` | The page is not the origin the session recorded (`www` vs bare domain, preview vs production), or a hand-built iframe | Mount from the page that shows it, on the origin the shopper is on. Never frame `hostedUrl` yourself |
| Redirects instead of rendering inline | An expected fallback: `legacy`, `no-embed-url`, or `no-top-navigation` in a builder preview | Read `detail.reason`; previews embed fine once deployed |
| Shopper returns from Swish, Klarna or iDEAL to a page with no checkout | The route dropped `qb_checkout_session`, or the page does not render the checkout on load | Keep the parameters and render the element on load. The order exists either way: poll, never start a second checkout |
| Return from a redirect payment reports the store id is unknown | `qb_checkout_shop` was dropped and site data was cleared | Keep both parameters; pass `storeId` from `readReturnedCheckout()` to `resume()` |
| The checkout disappears when the cart is emptied | Expected: its session names a cart that no longer exists | Render your own empty state from `qb:checkout-empty` / `onEmpty` |
| The checkout shows old lines after the page changed the cart | The cart changed outside the kit's cart store | Change it through `useCart()` / `Quickbutik.cart`, or call `checkout.cartUpdated()` |
| No Apple Pay or Google Pay | Wallets are not available inline yet | Expected; they work in the full-page checkout |

## Web components

| Symptom | Cause | Fix |
| - | - | - |
| A list renders nothing; console: "has no `<template>` child" | The `<template>` is inside a `<ul>` or `<div>` | Make the `<template>` a **direct child** of the repeating element and style that element as the grid |
| Elements render nothing; console: "has no shop to work against" | No `data-publishable-key`, no `configure()`, no `<qb-shop>` | Provide one of the three |
| `Quickbutik is not defined` in an inline script | The inline script ran before the deferred kit script | Use `type="module"`, an external `defer` script, or `DOMContentLoaded` |
| Prices show without a currency symbol and the console warns once | The platform did not state `product.currency` and no currency is configured | `data-currency="SEK"` or `configure({ currency })` as a fallback |
| Empty-state text flashes on load | `data-qb-show` element visible before the first bind | Add `hidden` in the markup |
| Console: "nothing handled `data-qb-action`" | A typo, or the element sits outside the component that owns the verb | Check the spelling and nesting |
| `<qb-add-to-cart>` never enables | Selection incomplete, `disabled` in the value template, or the wrapper is outside `<qb-product>` | Complete the selection, drop `disabled`, nest correctly |
| Quantity input adds 1 instead of the typed amount | `data-qb-quantity` used on the product page, or the input sits next to the wrapper | Product page: `data-qb-quantity-input` **inside** `<qb-add-to-cart>`. Cart rows: `data-qb-quantity` |
| `data-qb-show` on `<qb-options>` has no effect | Bindings on a scope-owning element itself are never applied | Put the binding on a wrapper or a child |
| "Add to cart" does nothing, no event | The mutation failed and the store captured the error | Read `Quickbutik.cart.getSnapshot().error`; style `[state="error"]` |
| Badge and drawer disagree after a client-side navigation | `configure()` ran twice | Call it once at startup |
| Tag names clash with another library | Both define `qb-*` | `defineElements({ prefix: "shop-" })` or `data-prefix="shop-"` |

## Currencies

| Symptom | Cause | Fix |
| - | - | - |
| Prices stay in the shop's currency after `setCurrency("EUR")` | The shop does not offer that currency (converter off, not enabled, or no rate) | Check `shop.currencies` or `describeCurrency(shop, "EUR")`. The fallback is deliberate, not an error |
| `ShopkitConfigError: currency must be a three-letter ISO 4217 code` | A malformed code in `currency`, `setCurrency()` or a per-call option | Pass a code like `"EUR"` |
| Server-rendered prices are one switch behind | Server components fetched before the switch | Refresh the route after `setCurrency()` (`router.refresh()` in Next.js) |
| One shopper sees another's currency | A cache key that ignores the currency | Key cached catalog data on the currency |
| The checkout charges the shop's currency although the page showed another | That currency is offered in `"display"` mode: shown converted, charged in the shop's currency | Expected. Say so near the checkout button when `mode === "display"` |

More in [Currencies](/kit/concepts/currencies).

## Consent and analytics

| Symptom | Cause | Fix |
| - | - | - |
| A cookie banner appeared after upgrading to 1.8.0 | Consent and analytics are on by default from 1.8.0 | Expected. Theme it, or turn it off with `consent: false` in the client config (`data-consent="false"` on the script tag) |
| The banner flashes for a shopper who already decided (server-rendered React) | The provider did not get the server's read | Pass `shopkit.consent.read()` as `consent={{ initialState }}` |
| No analytics events arrive | The shopper has not consented, or the shop has no GA4, GTM or Meta ids in the admin (one console warning says so) | Accept in the banner to test; set the ids in the Quickbutik admin |
| `purchase` is reported twice | You fire it yourself as well as `useOrderConfirmation` / `<qb-order-confirmation>` | Remove yours, or pass `trackPurchase: false` / `no-track-purchase` |
| Analytics scripts are blocked by the browser | The CSP does not allow the vendors | Allow them as listed in [Consent and analytics](/kit/concepts/consent-and-analytics#content-security-policy) |

## SEO

| Symptom | Cause | Fix |
| - | - | - |
| Duplicate or wrong `<title>` | A layout `metadata` export plus `<SEO />`, or `<qb-seo>` on a server-rendered head | One owner per page; or pass `skipTitle` / `skip-title` |
| No structured data on `generateMetadata` pages | Next's `Metadata` has no JSON-LD slot | Render `<JsonLd data={tags.jsonLd} />` in the page |
| No price in JSON-LD or share cards | The product states no currency and none was given as a fallback | Pass `currency` to `SeoProvider`, `buildSeo`, `ProductProvider` or `<qb-seo>` |
| Structured data shows a converted price | The product was read in a `"display"` currency | Read it for SEO with `{ currency: null }`. See [Currencies](/kit/concepts/currencies#seo-publish-the-price-you-charge) |
| Missing product page indexed with a 200 | A soft 404 from `<Suspense>` streaming | Render `<SEO noIndex />` in `notFound`, or await and call `notFound()` |
| Catalog not indexed | The page renders only in the browser | Render on the server or prerender; use the elements for interactive parts |


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