kit client, the shared cart store and the money() helper from Setup.
The storefront needs four routes: /, /products/:slug, /cart and /success/:orderNumber.
Catalog and search
Useproducts.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
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/maxPriceare in minor units and match the undiscounted list price. An inverted range is a400.categoryIdmatches direct members only. Child categories aren’t walked.search()returns no facet counts and has no option-value filter across the catalog.limitis 1–200 (default 50).
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
Available is not the same as in stock
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.Simple products
Simple products
For a product with no options,
resolveVariant returns its single variant, and variantId can be left out of cart.add.Prefer an object you can subscribe to?
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.Large catalogs and getBySlug
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).product.sections[].content are merchant HTML. Sanitize them before injecting.
Cart page
src/cart-page.ts
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.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
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:
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).