> ## 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 in plain JavaScript

> Catalog, product page with a variant picker, cart, checkout and thank-you page, rendered by hand with the framework-free client.

This guide builds a complete Vite + TypeScript shop with your own rendering. It assumes the `kit` client, the shared `cart` store and the `money()` helper from [Setup](/kit/vanilla/setup).

The storefront needs four routes: `/`, `/products/:slug`, `/cart` and `/success/:orderNumber`.

<Warning>
  Always escape merchant-authored strings before writing them to `innerHTML`. Names are free text, and descriptions are HTML from the merchant's editor. The snippets below use small `esc()` and `attr()` helpers for this.
</Warning>

## Catalog and search

Use `products.list()` for the catalog in the merchant's own order, and `products.search()` for anything a shopper navigates: a search box, a category page, a sorted grid.

```ts src/catalog.ts theme={null}
import { pickProductImage, resolveProductImageUrl } from "@quickbutik/kit"
import { kit, money } from "./kit"

export async function renderCatalog(grid: HTMLElement, q?: string, cursor?: string) {
  const page = q
    ? await kit.products.search({ search: q, sortBy: "price", sortOrder: "asc", limit: 24, cursor })
    : await kit.products.list({ limit: 24, cursor })

  grid.innerHTML = page.data.map((p) => {
    // NEVER image.path: it is a bare storage filename and 404s.
    const src = resolveProductImageUrl(pickProductImage(p), { width: 320, height: 320 })
    return `
      <a class="card" href="/products/${encodeURIComponent(p.slug ?? p.id)}">
        ${src ? `<img src="${attr(src)}" alt="${esc(p.name ?? "")}" width="320" height="320" loading="lazy">` : ""}
        <span>${esc(p.name ?? "")}</span>
        <span>${p.price === null ? "" : money(p.price, p.currency ?? undefined)}</span>
      </a>`
  }).join("")

  return page.has_more ? page.next_cursor : null // feed back as `cursor`
}
```

What to know about `search()`:

* Words are matched against name, SKU, GTIN, variant SKU/GTIN and option values. They're AND-ed and order-independent, and only the first 10 words count.
* `minPrice` / `maxPrice` are in **minor units** and match the **undiscounted** list price. An inverted range is a `400`.
* `categoryId` matches **direct members only**. Child categories aren't walked.
* `search()` returns no facet counts and has no option-value filter across the catalog.
* `limit` is 1–200 (default 50).

For a search-as-you-type box, pass an `AbortSignal` and abort the previous request on every keystroke, so only the newest answer renders:

```ts theme={null}
let controller: AbortController | undefined
input.addEventListener("input", async () => {
  controller?.abort()
  controller = new AbortController()
  try {
    const page = await kit.products.search({ search: input.value, limit: 10, signal: controller.signal })
    renderResults(page.data)
  } catch (e) {
    if ((e as Error).name !== "AbortError") throw e
  }
})
```

## Product page

Everything `<ProductProvider>` and `<qb-product>` do is available as **pure functions** from the root entry. A selection is a plain `{ [optionId]: valueId }` map.

```ts src/product.ts theme={null}
import {
  buildVariantMatrix, resolveVariant, buildPriceState, selectOptionValue,
  selectionForVariant, getOptionGroups, pickProductImage, resolveProductImageUrl,
  type VariantSelection,
} from "@quickbutik/kit"
import { kit, money } from "./kit"
import { cart } from "./cart-state"

export async function renderProduct(slug: string) {
  const product = await kit.products.getBySlug(slug) // Product | null
  if (!product) return renderNotFound()

  let selection: VariantSelection = {}
  const deepLink = new URLSearchParams(location.search).get("variant")
  if (deepLink) selection = selectionForVariant(product, Number(deepLink))

  function render() {
    const groups = buildVariantMatrix(product!, selection)   // option groups with per-value availability
    const variant = resolveVariant(product!, selection)      // ProductVariant | null until every group is chosen
    const price = buildPriceState(product!, selection)       // { amount, compareAtAmount, min, max, isRange }
    const hasOptions = getOptionGroups(product!).length > 0

    priceEl.textContent =
      price.amount !== null ? money(price.amount, product.currency ?? undefined)
      : price.isRange && price.min !== null ? `from ${money(price.min, product.currency ?? undefined)}`
      : price.min !== null ? money(price.min, product.currency ?? undefined) : ""

    // Swap to the picked variant's own image, falling back to the main one
    const image = pickProductImage(product!, { imageId: variant?.imageId ?? undefined }) ?? pickProductImage(product!)
    imageEl.src = resolveProductImageUrl(image, { width: 800, height: 800 }) ?? ""

    optionsEl.innerHTML = groups.map((g) => `
      <fieldset><legend>${esc(g.name ?? "")}</legend>
        ${g.values.map((v) => `
          <button type="button" data-option="${g.id}" data-value="${v.id}"
            aria-pressed="${v.selected}" aria-disabled="${!v.available}">${esc(v.name ?? "")}</button>`).join("")}
      </fieldset>`).join("")

    const missing = groups.filter((g) => g.selectedValueId === null)
    addBtn.disabled = (hasOptions && !variant) || cart.getSnapshot().pending > 0
    addBtn.textContent = missing.length ? `Select ${missing[0].name ?? "an option"}` : "Add to cart"
    addBtn.onclick = () => void cart.add({ productId: product!.id, variantId: variant?.id, quantity: 1 })
  }

  optionsEl.addEventListener("click", (e) => {
    const b = (e.target as HTMLElement).closest<HTMLButtonElement>("button[data-option]")
    if (!b) return
    // Repairs contradictions: keeps the new choice, clears what conflicts with it
    selection = selectOptionValue(product, selection, Number(b.dataset.option), Number(b.dataset.value))
    render()
  })
  render()
}
```

<AccordionGroup>
  <Accordion title="Keep unavailable values clickable">
    Availability is judged against the **other** groups. If Red has no XXL, picking Red marks XXL unavailable, and picking XXL first marks Red unavailable. If you `disable` those buttons, a shopper who chose XXL first is stranded. Mark them with `aria-disabled` and dim them in CSS. `selectOptionValue` clears whatever contradicts the click.
  </Accordion>

  <Accordion title="Available is not the same as in stock">
    `available` means "this combination exists and isn't hidden". `stock.stock === null` means the shop doesn't track inventory, so the product is **purchasable**. To dim sold-out values, read `stock` off the variants listed in each value's `variantIds`.
  </Accordion>

  <Accordion title="Simple products">
    For a product with no options, `resolveVariant` returns its single variant, and `variantId` can be left out of `cart.add`.
  </Accordion>

  <Accordion title="Prefer an object you can subscribe to?">
    `new ProductController(product, { initialVariantId })` wraps the same logic with `select()`, `clear()`, `reset()`, `selectVariant()`, `getSnapshot()` and `subscribe()`. See [Utilities](/kit/reference/utilities).
  </Accordion>

  <Accordion title="Large catalogs and getBySlug">
    The API has no slug filter, so `getBySlug()` pages through the catalog until it finds a match. That's fine on a small shop. On a large one, cache per slug, or build a slug → id map from `listAll()` at deploy time and call `products.get(id)`.
  </Accordion>
</AccordionGroup>

Product descriptions and `product.sections[].content` are merchant HTML. Sanitize them before injecting.

## Cart page

```ts src/cart-page.ts theme={null}
import { cart } from "./cart-state"
import { money } from "./kit"

cart.subscribe(() => {
  const { cart: c, pending, error } = cart.getSnapshot()
  if (!c || c.items.length === 0) {
    root.innerHTML = "<p>Your cart is empty.</p>"
    return
  }
  const off = pending ? "disabled" : ""
  root.innerHTML =
    c.items.map((item) => `
      <div class="line" data-id="${item.id}">
        ${item.imageUrl ? `<img src="${attr(item.imageUrl)}" alt="">` : ""}
        <div>${esc(item.productTitle ?? "")}${item.variantName ? ` · ${esc(item.variantName)}` : ""}${item.available ? "" : " · unavailable"}</div>
        <button data-qty="${item.quantity - 1}" ${off}>−</button>
        <span>${item.quantity}</span>
        <button data-qty="${item.quantity + 1}" ${off}>+</button>
        <span>${money(item.lineTotal, c.currency)}</span>
        <button data-remove ${off}>Remove</button>
      </div>`).join("") +
    `<p>Subtotal ${money(c.subtotal, c.currency)} · VAT ${money(c.totalTax, c.currency)} ·
       <strong>Total ${money(c.total, c.currency)}</strong></p>` +
    (error ? `<p role="alert">${esc(error.message)}</p>` : "")
})

root.addEventListener("click", (e) => {
  const t = e.target as HTMLElement
  const id = t.closest<HTMLElement>(".line")?.dataset.id // the LINE id, not the product id
  if (!id) return
  if (t.hasAttribute("data-remove")) void cart.removeItem(id)
  else if (t.dataset.qty) void cart.updateItem(id, Number(t.dataset.qty)) // 0 removes
})

void cart.load()
```

The cart is server-owned and **prices are recomputed on every read**, so don't cache cart numbers beyond the current render. Shipping is chosen in the hosted checkout, so the cart shows product totals only.

## Checkout button

```ts theme={null}
checkoutBtn.onclick = async () => {
  checkoutBtn.disabled = true
  try {
    const { url } = await kit.checkout.start({
      cartId: cart.getSnapshot().cart?.id,          // the cart the shopper filled
      successUrl: `${location.origin}/success`,     // only the ORIGIN is used
      cancelUrl: `${location.origin}/cart`,
      backUrl: `${location.origin}/`,               // the checkout's "continue shopping"
      language: "sv",
    })
    location.assign(url)                            // same-tab navigation; back returns to the cart
  } catch (e) {
    showError((e as Error).message)
    checkoutBtn.disabled = false
  }
}
```

<Note>
  `start()` without `cartId` checks out the cart **this client remembers**, and creates a new, empty one if it remembers none. The handoff refuses an empty cart with a `400`. Passing `cartId` from the cart store is always safe.
</Note>

For a "Buy now" button, `kit.checkout.buyNow({ productId, variantId }, { successUrl, backUrl })` adds the item and hands off in one call. It returns the same result plus the updated `cart`. Call `cart.hydrate(result.cart)` so the badge stays right if the shopper comes back.

To keep the shopper on your page instead of redirecting, see [Embedded checkout](/kit/concepts/embedded-checkout).

## Thank-you page

The hosted checkout returns the shopper to `/success/<orderNumber>?hash=…&t=…` on the `successUrl` origin. The order number in the URL is **never proof** of an order, so confirm it against the API:

```ts src/success.ts theme={null}
import { kit } from "./kit"
import { cart } from "./cart-state"

const sessionId = await kit.checkout.currentSessionId()
if (!sessionId) {
  render("Thank you for your order.") // a direct visit, or cookies cleared: nothing to poll
} else {
  const fromUrl = kit.checkout.parseReturnUrl(location.href)?.orderNumber // display fallback only
  render("Creating your order…")

  const outcome = await kit.checkout.pollConfirmation(sessionId, {
    onUpdate: (c) => render(`Status: ${c.status}`),
  })

  switch (outcome.kind) {
    case "completed":
      render(`Order #${outcome.orderNumber ?? fromUrl ?? ""} is confirmed. A confirmation email is on its way.`)
      void cart.refresh() // the kit already forgot the bought cart; empty the badge
      break
    case "failed":
      render("The payment did not go through. No order was created.")
      break
    case "timeout":
      render("Your payment is registered and the order is being created. Refresh in a moment.") // NOT a failure
      break
    case "aborted":
      break
  }
}
```

Order creation is asynchronous after payment (typically 6–17 seconds). The hosted checkout usually waits for it before redirecting, so `completed` on the first poll is the common case. Before completion, `kit.checkout.getSession(sessionId)` gives you `data.cart_products` and `data.order_total` if you want to show what was bought. More in [Checkout flow](/kit/concepts/checkout).

## Routing and hosting

The hosted checkout comes back with a **top-level GET** to `/success/<n>`, and shoppers deep-link to `/products/<slug>`. A single-page app therefore needs the host to serve `index.html` for unknown paths:

<CodeGroup>
  ```text Netlify / Cloudflare Pages (_redirects) theme={null}
  /*  /index.html  200
  ```

  ```json Vercel (vercel.json) theme={null}
  { "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }
  ```

  ```nginx nginx theme={null}
  location / { try_files $uri /index.html; }
  ```

  ```apache Apache (.htaccess) theme={null}
  RewriteEngine On
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteRule ^ index.html [L]
  ```
</CodeGroup>

Vite's dev server already does this. Keep the storefront and the thank-you page on the **same origin**, because the checkout session handle lives in a cookie. `successUrl` must be `https` in production. Any host is accepted, and `http` works on `localhost`.

## SEO without a framework

`buildSeo()` is pure, so you can write the head yourself. In a pure SPA, crawlers only see what the shell ships. Render on a server or prerender for real indexing (see [Server-side](/kit/vanilla/server-side) and [SEO](/kit/concepts/seo)).

```ts theme={null}
import { buildSeo, serializeJsonLd } from "@quickbutik/kit"

const tags = buildSeo({ product, baseUrl: "https://myshop.com", currency: "SEK", url: location.pathname, productPath: "/products/{slug}" })
document.title = tags.title
for (const node of tags.jsonLd) {
  const s = Object.assign(document.createElement("script"), { type: "application/ld+json", textContent: serializeJsonLd(node) })
  document.head.append(s)
}
```


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