Guide: Build a headless product page
This guide walks through building a complete product page — preorder CTA, payment-option modal, add-to-cart with the right selling plan, and a notify-me flow — using the STOQ Storefront SDK. Every step here was executed against a real shop before being written down; the troubleshooting section lists the actual errors you can hit and what they mean.
Before you start, you need: a STOQ-enabled shop with at least one enabled preorder offer that has variants attached, and the Shopify variant/product ids of one attached variant. Offer state lives in STOQ (dashboard → Preorders); a variant that merely looks sold out isn't preorderable until it's attached to an enabled offer.
Step 1 — Load the SDK
Script tag (any stack) — add to <head>:
<script src="https://cdn.jsdelivr.net/npm/@artossoftware/stoq-sdk@0/dist/stoq.min.js"
data-shop="your-shop.myshopify.com"
data-storefront-token="your-public-storefront-api-token"
defer></script>npm (Hydrogen/React):
npm install @artossoftware/stoq-sdk// root.tsx
<StoqProvider config={{ shop: 'your-shop.myshopify.com', storefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN }}>
<Outlet />
</StoqProvider>storefrontToken is your public Storefront API access token — the one your headless store already uses (in Hydrogen it ships as the PUBLIC_STOREFRONT_API_TOKEN env var; use the public token, never the private one, in browser code). It's required: the SDK reads everything from Shopify's edge in a single call with it (see How data loads).
Verify: the stoq:loaded event fires on window (script tag), or useStoq().status becomes 'ready' (React).
Step 2 — Read the variant's state
Ask by id — the SDK looks up the variant's availability itself (batched and cached). If you already have the variant object from your own product query, pass it ({ id, availableForSale, currentlyNotInStock }) and the lookup is skipped:
const state = await Stoq.client.getVariantState(VARIANT_ID) // number, numeric string, or GID
// { isPreorder, sellingPlanId, sellingPlanGid, shippingText,
// maxCount, remainingCount }React: const state = useStoqVariant(VARIANT_ID) (null while resolving).
This is the branching point for the whole page:
state | Render |
|---|---|
isPreorder: true | Preorder CTA (step 3) |
not preorderable, not availableForSale | Notify-me (step 4) |
| otherwise | Normal add to cart |
Step 3 — The preorder button + modal
Get the merchant-configured copy, open the modal on click, send the confirmed line to the cart:
const button = Stoq.client.preorderButtonFor(VARIANT_ID)
buyButton.textContent = button.label // e.g. "Preorder"
shippingEl.textContent = button.shippingText ?? '' // e.g. "Shipping: as soon as possible"
buyButton.onclick = async () => {
const confirmation = await Stoq.openPreorderModal({
variantId: VARIANT_ID,
productId: PRODUCT_ID,
productTitle: 'Classic Tee',
})
if (confirmation) await addLinesToCart([confirmation.cartLine])
}Two behaviors to know:
- The modal only renders when a choice is needed — multiple payment options (full vs partial payment) or a required acknowledgement checkbox. A single-option offer resolves immediately with the default option, exactly like the theme embed.
confirmation.cartLineis a finished Storefront APICartLineInputcarrying the chosen option'ssellingPlanId:
{ "merchandiseId": "gid://shopify/ProductVariant/5095…",
"sellingPlanId": "gid://shopify/SellingPlan/6904…", "quantity": 2 }Pass it to cartLinesAdd (or Hydrogen's CartForm). That selling plan on the line is the entire checkout-side mechanic — STOQ takes over from the order webhook (payments, holds, tagging).
React: <StoqPreorderButton variantId productId onAddToCart={line => …} /> does all of the above; useStoqPreorder(variantId) if you want your own UI.
Step 4 — The notify-me (back-in-stock) flow
One call opens the full modal — form, validation, duplicate handling, success state, merchant-configured texts/colors:
Stoq.openModal({ variantId: VARIANT_ID, productId: PRODUCT_ID, productTitle: 'Classic Tee' })Or inline in your own container: Stoq.openInlineForm({ container: '#notify', variantId, productId }). Or fully custom UI on the data call:
const result = await Stoq.client.createSignup({
variantId: VARIANT_ID,
productId: PRODUCT_ID, // required — the backend rejects signups without it
email: 'shopper@example.com',
})
// result.ok === true on creation; a repeat signup returns
// ok: false with errors.already_registered: trueReact: <StoqNotifyMeButton variantId productId onSuccess={…} /> or useStoqSignup().
Step 5 — Verify end-to-end
Run through this checklist (it's the one we use):
- Load:
stoq:loadedfires;getVariantStateon your attached variant showsisPreorder: truewith asellingPlanId. - CTA:
preorderButtonForreturns your dashboard-configured button label and shipping text. - Cart: the confirmed line carries
sellingPlanId; aftercartLinesAdd, the Shopify cart shows the deferred-purchase option on the line. - Checkout: place a test order — it appears in STOQ → Preorders → Orders with the offer attributed.
- Signup:
createSignupreturnsok: true(HTTP 201); the email appears in STOQ → Back in Stock → Waitlist. Submit again and you getalready_registered— dedup working. - Events: watch
stoq:*events in the console — your analytics integrations receive the same payloads as on themed stores.
Troubleshooting (real errors, real causes)
| Symptom | Cause |
|---|---|
isPreorder: false unexpectedly | The variant isn't attached to the enabled offer (it belongs to a disabled/old offer), or its availability doesn't match the offer type (STOQ-inventory offers need the variant out of stock but purchasable — currentlyNotInStock: true; Shopify-inventory offers need it in stock). Attach it in STOQ → Preorders, or check GET /api/v2/external/preorders/offers/:id/products/variants server-side. |
Console warning: variant availability lookup failed | The SDK's bare-id availability query failed (network, token). State degrades to isPreorder: false — retry, or pass the variant object with availability fields. |
createSignup → "shopify_variant_id and shopify_product_id are required" | Missing productId. It's required — the SDK throws early if you omit it. |
init rejects: "init requires a data source" | Neither storefrontToken nor data was provided. Pass your shop's public Storefront API token (or SSR-loaded metafield data). |
init rejects with StoqStorefrontApiError | The Storefront API call failed as a whole — usually a wrong/private token or an unsupported storefrontApiVersion. Use the shop's public Storefront API token. |
init rejects: the "settings" shop metafield is missing | STOQ hasn't synced its metafields for this shop yet (or a namespace override doesn't match). Save your STOQ settings once to trigger a sync. |
| State looks stale after changing an offer | Data is session-cached for 5 minutes (plus Shopify's edge caching) — call Stoq.client.refresh(). |
Working examples
Both ship in the SDK repo and run as-is:
examples/vanilla.html— complete script-tag product page (preorder CTA + modal + notify-me + event logging)examples/HydrogenBuyBox.tsx— drop-in Hydrogen buy box covering all three states
For the full API reference see the Headless & Hydrogen page and the package README.
