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

# Build a storefront with web components

> Complete pages built from the kit's custom elements: catalog with search, product page with a variant picker, cart, checkout and the thank-you page.

Every snippet on this page is plain HTML. It works unchanged with the [script tag](/kit/web-components/setup) (no build step) or with `@quickbutik/kit/elements` behind a bundler. The pages assume the shop is configured once per page, for example:

```html theme={null}
<script
  defer
  src="https://cdn.jsdelivr.net/npm/@quickbutik/kit@1.8.0/dist/quickbutik-kit.global.js"
  data-publishable-key="qb_pk_…"
  data-locale="sv-SE"
  data-consent-lang="sv"
  data-privacy-policy-url="/integritetspolicy"></script>
```

The script tag also mounts the kit's cookie consent banner and, once the shopper agrees, loads the shop's own GA4, GTM and Meta pixel. See [Consent and analytics](/kit/concepts/consent-and-analytics).

## The shell

```html theme={null}
<header>
  <a href="/">Min butik</a>
  <qb-currency-select></qb-currency-select>
  <a href="/cart">Varukorg (<qb-cart-count></qb-cart-count>)</a>
</header>

<footer>
  <a href="/integritetspolicy">Integritetspolicy</a>
  <!-- Reopens the consent dialog after the banner has gone -->
  <qb-consent-settings lang="sv"></qb-consent-settings>
</footer>

<style>
  qb-currency-select[empty] { display: none; }   /* the shop offers only its own currency */
</style>
```

`<qb-cart-count>` reads the remembered cart and **never creates one**, so a visitor who only browses (a crawler included) causes no write. `<qb-currency-select>` lists the currencies the shop offers; a choice reprices every product, list and cart on the page and is remembered. See [Currencies](/kit/concepts/currencies).

## Catalog

```html theme={null}
<input id="search" type="search" placeholder="Sök produkter" aria-label="Sök produkter">

<qb-product-list class="grid" limit="24" href-template="/products/:slug">
  <template>
    <a class="card" data-qb-attr="href:product.href">
      <qb-product-image width="400" height="400"></qb-product-image>
      <h3 data-qb-text="product.name"></h3>
      <span data-qb-text="product.priceFormatted"></span>
    </a>
  </template>
  <p data-qb-show="list.empty" hidden>Inga produkter hittades.</p>
</qb-product-list>

<script type="module">
  const list = document.querySelector("qb-product-list")
  document.getElementById("search").addEventListener("input", (event) => {
    // One setAttribute: the previous request is aborted, only the newest answer lands.
    list.setAttribute("search", event.target.value)
  })
</script>

<style>
  qb-product-list.grid { display: grid; gap: 1.5rem; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); }
  .card { color: inherit; text-decoration: none; }
  .card img { width: 100%; aspect-ratio: 1; object-fit: cover; }
</style>
```

| Attribute | |
| - | - |
| `limit` | 1 to 200 |
| `cursor` | Replaces the page with the one at that cursor (`list.nextCursor`) |
| `search`, `category-id`, `sort-by`, `sort-order`, `min-price`, `max-price` | Any of them switches to the search endpoint, filtered and sorted server-side. Prices are in minor units. `sort-order` only takes effect together with `sort-by` |
| `href-template` | Builds `product.href`; takes `:slug` and `:id` |
| `locale` | Number formatting for `product.priceFormatted`, which uses each product's own `product.currency` |

With no filter attribute it lists the catalog in the merchant's own order. The list re-runs its query when the shopper switches currency. Price filters (`min-price`, `max-price`, `sort-by="price"`) always run in the shop's own currency, whatever currency the rows are shown in. A `<qb-product-image>` inside a row is handed that row's product automatically.

<Note>
  The template is a **direct child** of `<qb-product-list>`, and the rows are inserted next to it, so the list element itself is the grid. Nesting the template inside a `<ul>` makes the list render nothing. See [Repeats](/kit/web-components/templates#repeats).
</Note>

### A category page

```html theme={null}
<qb-product-list class="grid" category-id="cat_12" sort-by="price" sort-order="asc" href-template="/products/:slug">
  <template>…</template>
</qb-product-list>
```

`category-id` matches direct members only; child categories are not walked. A "load more" that appends rather than replaces takes a few lines of script: see [Scripting](/kit/web-components/scripting#load-more).

## Product page

```html theme={null}
<qb-product slug="cotton-tee">
  <qb-product-image width="800" height="800" priority></qb-product-image>

  <h1 data-qb-text="product.name"></h1>
  <p>
    <strong data-qb-text="price.display"></strong>
    <s data-qb-show="price.onSale" hidden data-qb-text="price.compareAt.formatted"></s>
  </p>
  <div data-qb-html="product.description"></div>

  <!-- The variant picker: no option name appears anywhere -->
  <qb-options>
    <template>
      <fieldset data-qb-class="chosen:group.chosen">
        <legend data-qb-text="group.name"></legend>
        <qb-option-values class="values">
          <template>
            <button type="button" data-qb-action="select" data-qb-text="value.name"></button>
          </template>
        </qb-option-values>
      </fieldset>
    </template>
  </qb-options>
  <p data-qb-show="hasOptions" hidden data-qb-class="is-hidden:isComplete">Välj alla alternativ för att fortsätta.</p>

  <qb-add-to-cart>
    <input type="number" min="1" value="1" data-qb-quantity-input aria-label="Antal">
    <button type="button">Lägg i varukorg</button>
  </qb-add-to-cart>

  <qb-buy-now success-url="/success" back-url="/products/cotton-tee">
    <button type="button">Köp nu</button>
  </qb-buy-now>
</qb-product>

<style>
  .values button[data-selected="true"] { border-color: currentColor; font-weight: 600; }
  .values button[data-available="false"] { opacity: .35; text-decoration: line-through; }
</style>
```

### Naming the product

| | |
| - | - |
| `slug="cotton-tee"` | Looks the product up by slug. The API has no slug filter, so this walks the catalog: fine for a small shop, slow on a large one |
| `product-id="prod_27"` | One request |
| `el.product = product` | A product you already have (for example resolved server-side). Wins over both attributes |
| `variant-id="10"` | Preselect a variant, for `?variant=` deep links |
| `select-first-available` | Start on the first non-hidden variant instead of a price range. Ignores stock |

`<qb-product>` reflects `state`, `empty` (no such product) and `complete` / `incomplete` (whether the selection pins one variant), so a skeleton, a not-found message and a "choose a size first" hint are pure CSS. It emits `qb:product-change`, `qb:variant-change`, `qb:added-to-cart`, `qb:add-to-cart-blocked`, `qb:checkout-started` and `qb:product-not-found`.

### How the picker works

* `<qb-options>` repeats once per option group **the product actually has**, in the merchant's order. A product with no options renders nothing and gets `empty`, so the same page serves simple products.
* `<qb-option-values>` nested in a group row repeats once per value of that group. Each value row gets `data-selected`, `data-available` and `aria-pressed`.
* **Unavailable values stay clickable, on purpose.** Availability is judged against the other groups, and clicking an unavailable value keeps the new choice and clears whatever contradicts it. Dim them in CSS; never write `disabled` into the template.
* "Available" means the combination exists and is not hidden, not that it is in stock. After a full selection, `variant.soldOut` is true only when stock is tracked and exhausted.

### A `<select>` picker

`<select>` may only contain `<option>`, so a repeat element cannot live inside one. `data-qb-select-options` fills a `<select>` you wrote and wires its `change`:

```html theme={null}
<qb-options>
  <template>
    <label>
      <span data-qb-text="group.name"></span>
      <select data-qb-select-options>
        <option value="">Välj…</option>   <!-- your placeholder is kept -->
      </select>
    </label>
  </template>
</qb-options>
```

In a dropdown, unavailable values become disabled options, since a `<select>` has no way to show "dimmed but clickable".

### Swatches for one option only

`option="<id or name>"` on a `<qb-option-values>` placed **outside** any `<qb-options>` row pins it to one group, for layouts that want swatches for colour and plain buttons for everything else. It stops working the moment the merchant renames the option, and fails quietly. Prefer the generic picker above.

### Add to cart and buy now

* **`<qb-add-to-cart>`** wraps your button and keeps its `disabled` in step: not addable until the selection pins one variant, or while a cart mutation is in flight. It emits `qb:added`; the product emits `qb:added-to-cart`, or `qb:add-to-cart-blocked` with the groups still missing a choice.
* **`<qb-buy-now>`** is add-to-cart and checkout in one: it adds the selected variant to the remembered cart (an existing basket is carried along) and goes straight to the hosted checkout. `success-url`, `back-url`, `theme`, `no-redirect` and `disabled` mean the same as on `<qb-checkout-button>`. After it navigates it stays `pending` until the page is restored from the back/forward cache, so a second click cannot add the item twice.
* A plain `<button data-qb-action="buy-now" data-qb-success-url="/success">` inside `<qb-product>` does the same without the disabled-state management.

## Cart

```html theme={null}
<qb-cart>
  <p data-qb-show="cart.empty" hidden>Din varukorg är tom.</p>

  <qb-cart-items>
    <template>
      <article class="line">
        <img data-qb-attr="src:item.imageUrl" alt="">
        <div>
          <span data-qb-text="item.productTitle"></span>
          <small data-qb-text="item.variantName"></small>
        </div>
        <button type="button" data-qb-action="decrement" aria-label="Minska">−</button>
        <input type="number" min="0" data-qb-quantity data-qb-value="item.quantity" aria-label="Antal">
        <button type="button" data-qb-action="increment" aria-label="Öka">+</button>
        <strong data-qb-text="item.lineTotalFormatted"></strong>
        <button type="button" data-qb-action="remove">Ta bort</button>
      </article>
    </template>
  </qb-cart-items>

  <div data-qb-hide="cart.empty" hidden>
    <p>Varav moms <span data-qb-text="cart.totalTaxFormatted"></span></p>
    <p>Totalt <strong data-qb-text="cart.totalFormatted"></strong></p>
    <button type="button" data-qb-action="clear-cart">Töm varukorgen</button>
    <qb-checkout-button success-url="/success" back-url="/">
      <button type="button">Till kassan</button>
    </qb-checkout-button>
  </div>
</qb-cart>
```

* Every cart element on the page shares one store, so the badge and the cart never disagree.
* The quantity input commits on `change`, not per keystroke. `0` removes the line.
* Totals come from the server and are recomputed on every read. Shipping is chosen in the hosted checkout.
* `data-qb-action="clear-cart"` (or `clear`) deletes the remembered cart. With no cart it does nothing.

## Checkout

### Redirect: `<qb-checkout-button>`

```html theme={null}
<qb-checkout-button success-url="/success" back-url="/">
  <button type="button">Till kassan</button>
</qb-checkout-button>
```

It creates the checkout session on click and navigates the same tab to the hosted checkout (the back button returns to the cart). It is disabled while the cart is empty and guarded against a double click.

| Attribute | |
| - | - |
| `success-url` | Where the shopper comes back after paying. Relative values resolve against the current page. **Only its origin is used**: the shopper lands on `<origin>/success/<orderNumber>` |
| `back-url` | Where the checkout's "back to shop" links go |
| `theme` | `light` or `dark`. Omitting it lets the merchant's own checkout theme win |
| `no-redirect` | Do not navigate; read the URL from `qb:checkout-started` (`detail.url`) yourself |
| `disabled` | Block it outright |

<Info>
  `success-url` must be `https` in production. Plain `http` is accepted only on `localhost` and loopback, so a local page works against the live shop. Any https host is accepted; there is no domain to register.
</Info>

### Inline: `<qb-checkout>`

To keep the shopper on your site, render the checkout inside the page, on a route of its own:

```html theme={null}
<!-- /checkout -->
<qb-checkout success-url="/success" back-url="/cart" lang="sv" min-height="700"></qb-checkout>

<p id="empty" hidden>Din varukorg är tom.</p>
<script type="module">
  addEventListener("qb:checkout-empty", () => document.getElementById("empty").hidden = false)
  addEventListener("qb:checkout-fallback", (e) => console.info("full-page checkout:", e.detail.reason))
</script>
```

* It adds one iframe served from the checkout's own origin; payment, 3-D Secure and the receipt behave exactly as after a redirect.
* **It follows the cart.** Nothing is mounted while the cart is empty (`qb:checkout-empty`, once). Emptying the cart from the page takes the frame down; changing it updates the checkout in place.
* **Render it on load and keep the URL parameters.** Swish, Klarna, Vipps MobilePay, iDEAL and full-page 3-D Secure take the whole window and come back to this page with `?qb_checkout_session=…&qb_checkout_shop=…`. The element resumes that session and removes both parameters once the frame answers. A router or redirect that strips unknown query parameters breaks this.
* It falls back to the full-page checkout by itself on a legacy shop, when embedding is not enabled for the shop, inside a sandboxed app-builder preview, or when the frame never answers within 15 seconds (`qb:checkout-fallback` with `detail.reason`).
* Apple Pay and Google Pay are not available inline yet.

Attributes are read once, when the session is created. Details in [Embedded checkout](/kit/concepts/embedded-checkout).

## Thank-you page

After payment the hosted checkout sends the shopper to `<success-url origin>/success/<orderNumber>?hash=…&t=…`, whatever path `success-url` had. The order is created asynchronously, so the page polls until it exists:

```html theme={null}
<!-- served for /success/<orderNumber> -->
<qb-order-confirmation>
  <p data-qb-show="confirmation.loading" hidden>Skapar din order…</p>
  <div data-qb-show="confirmation.completed" hidden>
    Tack! Ordernummer <b data-qb-text="confirmation.orderNumber"></b>.
    En orderbekräftelse är på väg till din e-post.
  </div>
  <p data-qb-show="confirmation.failed" hidden>Betalningen gick inte igenom. Ingen order skapades.</p>
  <p data-qb-show="confirmation.timedOut" hidden>Din order behandlas fortfarande. Vi mejlar dig så snart den är klar.</p>
</qb-order-confirmation>
```

* The session id comes from `session-id`, a `?session_id=` parameter, or (the normal case) the session the kit remembered in a cookie when the checkout started. **The storefront and the thank-you page must share an origin.**
* A direct visit with nothing remembered lands in `status="unknown"`; render something neutral for it.
* `confirmation.orderNumber` can be empty even when completed; the number is also in the URL path, for display only.
* On `completed` the kit forgets the cart and the session, so the next page load starts with an empty basket.
* With analytics on (the default), the element fires the `purchase` event once the order exists, deduplicated across reloads. Add `no-track-purchase` to report it yourself.

<Warning>
  `timedOut` is **not** a payment failure. The order may still land. Never show "payment failed" for it.
</Warning>

### Static hosts need a rewrite

On a static host, `/success/12345` must serve your thank-you file:

| Host | Rule |
| - | - |
| Netlify, Cloudflare Pages (`_redirects`) | `/success/* /success.html 200` |
| Vercel (`vercel.json`) | `"rewrites": [{ "source": "/success/:n", "destination": "/success.html" }]` |
| nginx | `location /success/ { try_files $uri /success.html; }` |
| Apache (`.htaccess`) | `RewriteRule ^success/ success.html [L]` |

Add `<meta name="robots" content="noindex, nofollow">` to the cart and thank-you pages.

## SEO on client-rendered pages

`<qb-seo>` writes the title, description, canonical, OpenGraph, Twitter card and schema.org JSON-LD into `<head>` from the product it sits inside:

```html theme={null}
<qb-product slug="cotton-tee">
  <qb-seo base-url="https://minbutik.se" product-path="/products/{slug}" currency="SEK" organization></qb-seo>
  …
</qb-product>
```

Everything it writes is tagged `data-qb-seo` and removed on update, so a soft navigation leaves nothing stale. Its prices use the product's own currency: on a shop with a [display currency](/kit/concepts/currencies#seo-publish-the-price-you-charge), a shopper browsing in it would publish a converted price that no order is charged, so server-rendered structured data in the shop's currency is the safer source.

<Warning>
  `<qb-seo>` only helps crawlers that run JavaScript and share-card scrapers. For indexable catalog and product pages, render the HTML on the server with the plain client and `buildSeo()`, and use the elements for the interactive parts. Never put `<qb-seo>` on a page whose server already writes the head: two owners of one `<title>` is worse than either. See [SEO](/kit/concepts/seo).
</Warning>

## Testing the loop

Add, cart, checkout, hosted checkout, back to `/success/<n>`, confirmation. A shop that has not activated Quickbutik Payments runs the **demo** checkout: the shopper walks the real checkout, no payment is taken and no order is created, so the thank-you page is not exercised until payments are activated. See [Going live](/kit/concepts/going-live).


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