Bolder Headless Storefront — Integration Guide
A guide to embedding Bolder shopping functionality into any website without a Bolder-hosted theme.
Three-doc structure:
Table of Contents
- Overview
- Quick Start — Script Tag
- Quick Start — npm Package
- createStorefront(options)
- Emitter
- Config Reference
- Pre-load Queue Pattern
- Cart
- Cross-origin cart identity
- Search
- Forms
- Checkout
- Embedded mode - Hosted redirect mode - Return detection - Shopping beacon integration
- Bundled UI components
Overview
Bolder Headless lets you embed cart and other storefront functionality into any page — a custom HTML site, a framework-based SPA, a landing page — without building a full Liquid theme.
There are two integration paths:
| Path | Use when… |
|---|
Script tag (storefront.umd.cjs) | No build tooling; drop a <script> into any HTML page |
npm package (@bolder/storefront) | Using Vite, Webpack, or another bundler; want tree-shaking |
Both paths expose the same JavaScript API. The script-tag path auto-initialises window.Bolder; the npm path exports ES modules you compose yourself.
Quick Start — Script Tag
Copy this loader snippet into your site's <head> or before </body>. It loads storefront.umd.cjs asynchronously and exposes Bolder.ready() so your code can queue up before the script has finished loading — no separate data-* tag or queue-shim tag needed, it's one block:
Pass your shop subdomain and (optionally) a 6th argument to override apiBase — omit it in production, where the SDK derives the API URL from shop itself:
<script>
(function(b,o,l,d,e,r){
b.Bolder=b.Bolder||{};b.Bolder.ready=b.Bolder.ready||function(n){
(b.Bolder.q=b.Bolder.q||[]).push(n)};var s=o.createElement(l);
s.src=e;s.async=1;s.dataset.shop=d;r&&(s.dataset.apiBase=r);
o.head.appendChild(s)})(window,document,'script','acme',
'https://assets.bolder.run/storefront.umd.cjs?17');
Bolder.ready(function(storefront) {
storefront.cart.on('updated', function() {
document.querySelector('#cart-count').textContent = storefront.cart.units
})
})
</script>
The loader's positional arguments (after window/document/'script') are shop, src (the SDK URL), and an optional 6th apiBase override. They end up as data-shop/data-api-base on the dynamically-created script tag, which is what createStorefront() reads internally to auto-init. You never write those attributes by hand.
If you need to initialise manually with options the loader doesn't expose, skip the loader and call createStorefront() yourself once the script has loaded — see createStorefront(options).
Quick Start — npm Package
npm install @bolder/storefront
import { createStorefront } from '@bolder/storefront'
const storefront = createStorefront({ shop: 'acme' })
storefront.cart.on('updated', function() {
document.querySelector('#cart-count').textContent = storefront.cart.units
})
createStorefront(options)
Factory function that wires up the cart and the shared context object used by components.
import { createStorefront } from '@bolder/storefront'
const storefront = createStorefront({
shop: 'acme', // shop subdomain (required unless apiBase is set)
apiBase: 'https://...', // override API base URL (optional)
locale: 'es-CL', // locale hint (default: 'es-CL')
currency: 'CLP', // currency hint (optional)
onBeforeAdd: function(proceed) { /* gate for add-to-cart */ proceed() },
onError: function(msg) { /* custom error handler */ },
})
Return value
| Property | Type | Description |
|---|
cart | Cart | Cart instance — see the Cart section in the Core API reference. |
config | Object | Resolved configuration object. |
shared | Object | Context object threaded into component factories. |
mount(Factory, target, data?, opts?) | Function | Mount a component factory onto a DOM element. |
Config priority
Options are merged in this order (highest priority first):
- Explicit options passed to
createStorefront()
window.__BOLDER_CONFIG__ global object
data-* attributes on the <script> tag that loaded storefront.umd.cjs
- Built-in defaults (
locale: 'es-CL')
mount(Factory, target, data?, opts?)
storefront.mount(MyComponent, '#container', { someOption: true })
| Argument | Type | Description |
|---|
Factory | Function | A component factory |
target | `string \ | Element` | CSS selector or DOM element to mount into |
data | Object | Initial data / settings passed to the component |
opts | Object | Mount options (e.g. { shadow: false } to skip Shadow DOM) |
Returns { update(data), unmount() }.
Emitter
A lightweight event emitter mixed into Cart and available as a standalone export for building custom event buses.
import { Emitter } from '@bolder/storefront'
const bus = new Emitter()
bus.on('change', handler)
bus.trigger('change', payload)
bus.off('change', handler)
bus.once('change', handler) // fires once then auto-removes
// Mix into an existing object
const obj = {}
Emitter(obj)
obj.on('ping', handler)
| Method | Description |
|---|
on(events, fn) | Register a listener. events may be space-separated. |
once(event, fn) | Register a one-time listener. |
off(events?, fn?) | Remove a specific listener, all listeners for an event, or all listeners with '*'. |
trigger(event, ...args) | Fire all listeners for an event. |
Config Reference
All options accepted by createStorefront():
| Option | Type | Default | Description |
|---|
shop | string | — | Shop subdomain (e.g. "acme"). Used to build apiBase and as the default search feed's shopSubdomain. Required unless apiBase is set. |
apiBase | string | https://<shop>.bootic.net | Full base URL for API requests. |
searchUrl | string | Bolder's hosted search service | Override the WebSocket URL storefront.search connects to. Rarely needed. |
locale | string | "es-CL" | Locale hint for components. |
currency | string | — | Currency code hint (e.g. "CLP", "USD"). |
widgets | Object | {} | Widget configuration forwarded to components via shared. |
strings | Object | {} | Localisation strings forwarded to components via shared. |
helpers | Object | {} | Helper functions forwarded to components via shared. |
onBeforeAdd | function(proceed) | — | Gate function called before every quickAdd. Must call proceed() to continue. |
onError | function(message) | — | Error handler for cart mutation failures. Defaults to console.warn. |
Pre-load Queue Pattern
The loader snippet in Quick Start — Script Tag already implements this: Bolder.ready(fn) pushes fn onto window.Bolder.q if the SDK hasn't finished loading yet, and calls fn immediately once it has. You can call Bolder.ready() as many times as you like, from as many separate <script> blocks as you like (e.g. splitting embed setup from page-specific logic) — every callback queues or fires the same way, in call order.
<script>Bolder.ready(function(storefront) { /* runs once ready, queued or not */ })</script>
There's no manual queue array to manage — don't push onto window.Bolder.q directly, always go through Bolder.ready().
Cart
storefront.cart is an event emitter (see Emitter) wrapping the shop's cart session.
storefront.cart.on('updated', function() {
document.querySelector('#cart-count').textContent = storefront.cart.units
})
// Add by numeric product/variant id — see addItem below for the full signature
storefront.cart.addItem(productId, variantId, 1)
// Add by a product's URL slug instead — no need to look up its numeric id yourself,
// the server resolves it (product must have exactly one variant, or you'll need
// to resolve the variant id yourself and use addItem)
storefront.cart.quickAdd('aurora-runner', function(item) {
if (item) console.log('added', item)
})
quickAdd(productIdOrSlug, fn?) — Add a product by numeric ID or by URL slug. A
numeric-looking string/number is sent as product_id; anything else is sent as product_slug and resolved server-side. Respects onBeforeAdd (see Config Reference) the same way addItem does.
addItem(productId, variantId, quantity, formData?, fn?) — Add or increase
quantity of a line item. Pass variantId: -1 to let the server pick the product's default/only variant. formData.product_slug resolves a slug the same way quickAdd does internally.
Cross-origin cart identity
The cart session is identified server-side by an id (_btc_oid) that hosted themes carry in a same-site cookie automatically. A headless page served from a different origin than apiBase — true for almost any headless integration that isn't proxied under the shop's own domain — can't rely on that cookie the normal way: SameSite/Secure cross-site cookie rules would require the API to be served over HTTPS just for the cookie to be honored.
To sidestep that entirely, the SDK's cart client doesn't depend on the cookie:
- Every cart response includes the cart's id (
cart.id), which the SDK caches in
localStorage and sends back as an X-Cart-Token header on subsequent cart requests — a plain custom header, not a cookie, so none of the SameSite/HTTPS requirements apply. Server-side, cart_cookie (the single method every cart-resolving code path in app.rb goes through) accepts either the cookie or this header, in that order.
- The one exception is the checkout button itself: it's a real
<form method="post">
full-page navigation (via cart-table-v3), which can't carry a custom header. It carries the same id as a hidden cart_token field instead, which cart_cookie also accepts as a final fallback.
- The cookie is still set and read too (unchanged for hosted themes, and same-origin
headless integrations get it for free) — the token/field are purely an additional path, not a replacement.
This means cart identity survives even with cookies fully blocked (verified by wiping all cookies mid-session and continuing to add items / checking out — same cart throughout).
Search
storefront.search gives you live, typeahead-style product search over a WebSocket connection to Bolder's hosted search service — the same backend that powers hosted themes' search boxes.
Wires an <input> element up end to end: connects on focus (so the connection is warm before the first keystroke), debounces input, and emits results. Returns an Emitter — call .destroy() to unbind.
const handle = storefront.search.bindTo(document.querySelector('#search-input'), {
minChars: 3, // default: 3
debounce: 150, // ms, default: 150
})
handle.on('results', (evt, results, query) => {
renderResults(results.products, results.groups)
})
handle.on('cleared', () => clearResults())
handle.on('error', (evt, err) => console.error('Search failed', err))
// later, e.g. on component unmount:
handle.destroy()
You don't need to manage the connection yourself — bindTo reuses one socket per storefront instance and lets it disconnect on inactivity automatically.
search(query, opts?, fn) — build your own UI
Lower-level: runs a single query without binding to an input. Auto-connects if needed.
storefront.search.search('running shoes', { minChars: 3 }, (err, results) => {
if (err) return console.error(err)
console.log(results.products, results.groups)
})
connect(fn?) — pre-warm the connection
Rarely needed directly — both search() and bindTo() connect automatically. Useful if you want to open the connection earlier than the first query (e.g. as soon as a search UI becomes visible, before the user has typed anything) to shave connection latency off the first result.
storefront.search.connect((err, socket) => {
if (err) return console.error('Could not connect to search', err)
})
Results shape
{
products: [
{ id, name, slug, url, type, vendor, price, image, vars, skus }
],
groups: [
{ type, name, slug } // Collection, ProductType, Vendor, ProductSet, or Tag matches
]
}
Which fields are present depends on config passed to createStorefront() — image and price require withImages/withPrices (both on by default for headless).
storefront.forms lets a headless shop submit to a Bootic contact form (the same forms you build in the shop admin) without any Bootic-hosted markup. Useful for a contact page or feedback form on a statically-generated site, where the form's HTML needs to be part of the static page for SEO but the submission still needs to reach the shop's contacts.
get(slug, fn)
Fetches a form's field definitions so you can render your own inputs.
storefront.forms.get('contact', (err, form) => {
if (err) return console.error(err)
console.log(form.title, form.message) // form.message is the form's body/intro text
form.contactFields.forEach(field => {
// field: { key, name, field_type, required, value, options? }
})
form.customFields.forEach(field => { /* same shape */ })
})
Submits data collected from your own form UI. Pass the form object returned by get() — submit uses it to sort your flat data object into the contact_fields/ custom_fields split the backend expects, keyed by each field's key.
storefront.forms.get('contact', (err, form) => {
myForm.addEventListener('submit', e => {
e.preventDefault()
storefront.forms.submit(form, {
name: myForm.name.value,
email: myForm.email.value,
message: myForm.message.value, // a custom field, e.g.
}, (err, result) => {
if (err) return showErrors(err.errors)
showSuccess(form.sentMessageText)
})
})
})
On validation failure err is { success: false, errors: [{ field, messages }] }.
Checkout
Bolder's checkout can be integrated in two modes. Choose based on the UX you want and the payment methods your shop uses.
| Mode | UX | Gateway redirects | Setup |
|---|
| Embedded | Customer stays on your page; checkout opens in a slide-out iframe | Handled transparently — SDK navigates the top-level window when a gateway (PayPal, Webpay, Khipu) redirects | Import @bolder/checkout or include checkout.umd.cjs |
| Hosted redirect | Customer navigates to Bolder's checkout page, then returns to your page | Native — no iframes involved | Pass return_url in the checkout link |
Both modes share the same return-detection API so your page always knows when a customer comes back after payment.
Embedded mode
The checkout opens in a closeable slide-out panel on top of your page. When an inline payment succeeds the SDK fires a success event directly. When a gateway redirect is required (PayPal, Webpay, Khipu), the SDK breaks out of the iframe and navigates the top-level window — on return, handleReturn() detects the result.
Install:
npm install @bolder/checkout
# or use the script tag: https://assets.bolder.run/checkout.umd.cjs
Usage with @bolder/storefront cart:
import { createStorefront, openCheckout } from '@bolder/storefront'
// or: import { openCheckout } from '@bolder/checkout'
const storefront = createStorefront({ shop: 'acme' })
// Cart's checkout() returns a URL — open it in the embedded slider.
document.querySelector('#checkout-btn').addEventListener('click', function() {
storefront.cart.checkout(function(err, url) {
if (err) return
openCheckout({ checkoutUrl: url })
})
})
The ShoppingBeacon component wires this automatically — you don't need to handle it manually if you're using the beacon.
Usage with your own order API (no storefront cart):
import { openCheckout } from '@bolder/checkout'
// Your backend creates the order and returns a checkout_url.
const { checkout_url } = await fetch('/api/orders', { method: 'POST', ... }).then(r => r.json())
const session = openCheckout({ checkoutUrl: checkout_url })
session.on('success', () => showConfirmation())
session.on('closed', () => console.log('checkout closed'))
Hosted redirect mode
The customer navigates to Bolder's checkout UI (same as for a regular hosted shop). When they complete (or cancel) their order, Bolder redirects them back to the URL you specified as return_url.
This is the simpler mode — no iframes, no postMessage, no SDK required on the checkout page itself.
Navigating to the checkout:
Use redirectToCheckout() — it accepts the same URL options as openCheckout() and appends return_url automatically:
import { redirectToCheckout } from '@bolder/checkout'
// or: import { redirectToCheckout } from '@bolder/storefront'
redirectToCheckout({ checkoutUrl: cartData.checkout_url })
// Optional: override the return page (defaults to the current page)
redirectToCheckout({ checkoutUrl: cartData.checkout_url, returnUrl: 'https://mysite.com/thanks' })
Script-tag: Bolder.checkout.redirect({ checkoutUrl: cartData.checkout_url })
Bolder stores return_url on the checkout session when the checkout page first loads and uses it to redirect back after any gateway round-trip. The return_url must be a URL whose hostname matches the shop's configured domain or an allowed embed origin — Bolder rejects unknown hosts to prevent open redirects.
What the customer receives on return:
Bolder appends two parameters before redirecting back:
https://your-site.com/products/hat?checkout_session=ABC123&checkout_status=success
| Parameter | Values |
|---|
checkout_session | The order permalink — identifies which order was placed |
checkout_status | success or cancelled |
Return detection
Whether you use embedded or hosted redirect mode, load @bolder/checkout on the page the customer returns to. It runs handleReturn() automatically at module load — before any framework router can consume the URL — cleans the params, and fires a bolder:checkout:return event.
Listening for the event (registers before module load completes):
import { handleReturn } from '@bolder/checkout'
// handleReturn() already ran by the time the next line executes.
// Use document.addEventListener *before* the import for early listeners,
// or use getLastReturn() for components that mount later.
document.addEventListener('bolder:checkout:return', function(e) {
const { sessionCode, status } = e.detail // status: 'success' | 'cancelled'
if (status === 'success') showConfirmationBanner(sessionCode)
})
Checking in a late-mounting component (misses the event):
import { getLastReturn } from '@bolder/checkout'
// or: import { getLastCheckoutReturn } from '@bolder/storefront'
// Call this inside your component's mount/init callback:
const ret = getLastReturn()
if (ret?.status === 'success') showConfirmationBanner(ret.sessionCode)
if (ret?.status === 'cancelled') showRetryPrompt()
handleReturn() return value — { sessionCode, status } if checkout params were present, undefined otherwise. Also available via Bolder.checkout.handleReturn() in script-tag usage.
URL cleanup — handleReturn() removes checkout_session and checkout_status from the URL via history.replaceState so a page refresh doesn't re-trigger the handler.
The ShoppingBeacon component integrates both modes automatically:
- Embedded mode: the "Proceed to checkout" button in the cart slide-out calls
Bolder.checkout.open({ checkoutUrl }). The beacon passes return_url (the current page URL) and origin to the checkout iframe automatically.
- Return detection: on every page load, if
getLastReturn() reports
status === 'success', the beacon displays a success toast notification. In hosted themes the toast uses the styled Toasts module; in headless mode a self-contained fallback renders instead.
No additional wiring is needed — mount the beacon and both flows work out of the box.
import { createStorefront, ShoppingBeacon } from '@bolder/storefront'
const storefront = createStorefront({ shop: 'acme' })
storefront.mount(ShoppingBeacon, '#beacon-container')
To handle the return yourself and suppress the beacon's built-in toast, listen to the event before the beacon mounts and call e.stopImmediatePropagation(), or simply don't use the beacon and wire your own UI around getLastReturn().
Bundled UI components
@bolder/storefront also re-exports a couple of prebuilt component factories, meant to be mounted with storefront.mount() like any other component. Their full options are documented in components-reference.md (they're the same base components used by hosted themes via Bootic.components.render(...)); this section only covers the headless import path.
import { createStorefront, CartTable, FeedbackButton } from '@bolder/storefront'
const storefront = createStorefront({ shop: 'acme' })
storefront.mount(CartTable, '#cart-table', { show_cart_gauge: true, cart_gauge_limit: 50000 })
storefront.mount(FeedbackButton, '#feedback-button')
CartTable — renders cart line items (quantity controls, descriptions, bundle/volume-discount
hints, optional cart gauge). Purely a renderer: it doesn't wire clicks itself, so headless callers (like ShoppingBeacon) mount it and wire interactions on top.
FeedbackButton — floating feedback/help button. Submits through storefront's forms instance
when available, so it works without the hosted-theme Forms AMD module.
ShoppingBeacon (see above) already mounts its own CartTable internally — you only need to mount CartTable directly if you're building a custom cart UI outside of the beacon.