Home
Headless & Hydrogen

Guide: Build a headless product page

Step-by-step: preorder button, payment-option modal, and back-in-stock signups on a headless storefront with the STOQ SDK — every step verified end-to-end.

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.

Note

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:

stateRender
isPreorder: truePreorder CTA (step 3)
not preorderable, not availableForSaleNotify-me (step 4)
otherwiseNormal 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.cartLine is a finished Storefront API CartLineInput carrying the chosen option's sellingPlanId:
{ "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: true

React: <StoqNotifyMeButton variantId productId onSuccess={…} /> or useStoqSignup().

Step 5 — Verify end-to-end

Run through this checklist (it's the one we use):

  1. Load: stoq:loaded fires; getVariantState on your attached variant shows isPreorder: true with a sellingPlanId.
  2. CTA: preorderButtonFor returns your dashboard-configured button label and shipping text.
  3. Cart: the confirmed line carries sellingPlanId; after cartLinesAdd, the Shopify cart shows the deferred-purchase option on the line.
  4. Checkout: place a test order — it appears in STOQ → Preorders → Orders with the offer attributed.
  5. Signup: createSignup returns ok: true (HTTP 201); the email appears in STOQ → Back in Stock → Waitlist. Submit again and you get already_registered — dedup working.
  6. Events: watch stoq:* events in the console — your analytics integrations receive the same payloads as on themed stores.

Troubleshooting (real errors, real causes)

SymptomCause
isPreorder: false unexpectedlyThe 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 failedThe 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 StoqStorefrontApiErrorThe 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 missingSTOQ 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 offerData 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.