Skip to main content
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. The storefront needs four routes: /, /products/:slug, /cart and /success/:orderNumber.
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.
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.
src/catalog.ts
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:

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.
src/product.ts
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.
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.
For a product with no options, resolveVariant returns its single variant, and variantId can be left out of cart.add.
new ProductController(product, { initialVariantId }) wraps the same logic with select(), clear(), reset(), selectVariant(), getSnapshot() and subscribe(). See Utilities.
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).
Product descriptions and product.sections[].content are merchant HTML. Sanitize them before injecting.

Cart page

src/cart-page.ts
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

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

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:
src/success.ts
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.

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:
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 and SEO).