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 |
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 |
| 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) |
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.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" |
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 |
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 |
| 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 |