Tier 2 — Headless / web component integration¶
Generated from the canonical integration guide
This page is generated from docs/integration/headless.md
— the single source of truth shared with the public
marketing site. Edit the canonical file, then run
npm --prefix tools/integration-docs-derive run derive.
Package:
@bc-subscriptions/storefront-webcomponentAPI surface reference:packages/storefront-webcomponent/README.md
Who this is for¶
Developers building custom headless storefronts on any framework — Next.js (non-Catalyst), Nuxt, SvelteKit, Remix, Hydrogen, plain HTML — or any framework-agnostic environment. Also the right tier for Stencil themes where you want direct DOM control over widget placement (rather than the automatic Script Manager injection of Tier 1).
The web component (<bc-subscriptions-widget>) wraps the same SvelteKit subscriber portal that Stencil uses, compiled to a custom element via Svelte 5's customElement target.
Prerequisites¶
- Deployed
apps/apiWorker, with its base URL - BC store hash (
settings.store_hashin Stencil; available from your BC storefront channel in headless) - At least one subscription plan configured in bc-subscriptions admin
Install¶
Option A — CDN <script> tag (no build step)¶
<script
src="https://bc-subscriptions-cdn.pages.dev/v1/bc-subscriptions-widget.iife.js"
async
></script>
Place this in your <head> or just before </body>. The IIFE auto-registers <bc-subscriptions-widget> on evaluation. No module bundler needed.
Option B — npm (bundler-aware consumers)¶
Then import as a side-effect to register the custom element:
The import registers the element synchronously. After the import, <bc-subscriptions-widget> is available anywhere in your JSX, Svelte templates, Vue templates, or plain HTML.
Minimal working example¶
Plain HTML / Stencil manual placement¶
<!doctype html>
<html>
<head>
<script
src="https://bc-subscriptions-cdn.pages.dev/v1/bc-subscriptions-widget.iife.js"
async
></script>
</head>
<body>
<h1>Product — Gourmet Coffee Subscription</h1>
<bc-subscriptions-widget
data-store-hash="abc12345"
data-api-base-url="https://subs-api.bigcommerce-testing-7727.workers.dev"
data-bc-product-id="42"
></bc-subscriptions-widget>
<!-- existing add-to-cart markup -->
</body>
</html>
Replace abc12345 with your store hash and 42 with the BC product entity ID. If data-bc-product-id is omitted, the widget attempts to read window.BCData.product_attributes.id (available on BC storefronts).
React (Next.js, Remix, Hydrogen)¶
// components/SubscriptionWidget.tsx
'use client'; // Next.js App Router: this is a client component
import { useEffect } from 'react';
interface Props {
storeHash: string;
apiBaseUrl: string;
bcProductId: number;
}
export function SubscriptionWidget({ storeHash, apiBaseUrl, bcProductId }: Props) {
useEffect(() => {
// dynamically import to avoid SSR evaluation of the custom element
import('@bc-subscriptions/storefront-webcomponent');
}, []);
return (
<bc-subscriptions-widget
data-store-hash={storeHash}
data-api-base-url={apiBaseUrl}
data-bc-product-id={String(bcProductId)}
/>
);
}
// app/products/[slug]/page.tsx
import { SubscriptionWidget } from '@/components/SubscriptionWidget';
export default async function ProductPage({ params }: { params: { slug: string } }) {
const product = await getProduct(params.slug); // your existing data fetching
return (
<main>
<ProductDetails product={product} />
<SubscriptionWidget
storeHash={process.env.NEXT_PUBLIC_BC_STORE_HASH!}
apiBaseUrl={process.env.NEXT_PUBLIC_SUBS_API_URL!}
bcProductId={product.entityId}
/>
</main>
);
}
TypeScript + React 18: You must declare the element in
JSX.IntrinsicElements. React 19+ handles unknown custom elements natively.// types/custom-elements.d.ts declare namespace JSX { interface IntrinsicElements { 'bc-subscriptions-widget': React.DetailedHTMLProps< React.HTMLAttributes<HTMLElement> & { 'data-store-hash': string; 'data-api-base-url': string; 'data-bc-product-id'?: string; 'data-initial-auth-token'?: string; }, HTMLElement >; } }
SvelteKit¶
<!-- src/lib/components/SubscriptionWidget.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
export let storeHash: string;
export let apiBaseUrl: string;
export let bcProductId: number;
onMount(async () => {
await import('@bc-subscriptions/storefront-webcomponent');
});
</script>
<bc-subscriptions-widget
data-store-hash={storeHash}
data-api-base-url={apiBaseUrl}
data-bc-product-id={String(bcProductId)}
/>
Vue 3¶
<!-- components/SubscriptionWidget.vue -->
<template>
<bc-subscriptions-widget
:data-store-hash="storeHash"
:data-api-base-url="apiBaseUrl"
:data-bc-product-id="String(bcProductId)"
/>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
const props = defineProps<{
storeHash: string;
apiBaseUrl: string;
bcProductId: number;
}>();
onMounted(async () => {
await import('@bc-subscriptions/storefront-webcomponent');
});
</script>
Add 'bc-subscriptions-widget' to compilerOptions.isCustomElement in vite.config.ts to suppress Vue warnings:
// vite.config.ts
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag.startsWith('bc-subscriptions-'),
},
},
}),
],
});
Attribute API¶
| Attribute | Type | Required | Default | Notes |
|---|---|---|---|---|
data-store-hash |
string |
yes | — | BC store hash. Missing → visible error banner |
data-api-base-url |
string |
yes | — | Base URL of the deployed apps/api Worker |
data-bc-product-id |
number |
no | reads window.BCData |
PDP product context |
data-initial-auth-token |
string |
no | null |
Pre-authenticated portal JWT — skips magic-link flow |
Configuration¶
Theming¶
The element uses light DOM (shadow: 'none'). Style it with standard CSS:
bc-subscriptions-widget {
display: block;
margin-top: 1rem;
font-family: inherit;
}
bc-subscriptions-widget .subs-plan-card {
border: 1px solid var(--color-border);
border-radius: 0.5rem;
}
Auth integration¶
The magic-link flow is self-contained — the widget sends the shopper's email, they receive a link, and clicking it sets data-initial-auth-token for subsequent page loads. If your headless storefront already has a customer session (e.g., BC GraphQL customer token), you can pass it directly:
const token = await fetchPortalToken(bcCustomerId); // your server-side logic
element.setAttribute('data-initial-auth-token', token);
Portal tokens are short-lived JWTs signed by the API Worker's JWT_SECRET.
Common pitfalls¶
1. Custom element registers but renders blank¶
Cause: data-store-hash or data-api-base-url is missing or incorrect.
Fix: Open DevTools → Elements, inspect the <bc-subscriptions-widget> tag, and verify both attributes are present. Check the Network tab for failed calls to the API Worker.
2. customElements.define() throws "already defined"¶
Cause: The element is registered twice — once from the CDN IIFE and once from the npm import.
Fix: Pick one delivery path (CDN or npm) per page. If using Next.js, ensure the dynamic import runs only once (wrap in useEffect with an empty dependency array).
3. SSR renders empty element, hydration mismatch¶
Cause: The custom element registration is client-only but the element tag is present in server-rendered HTML.
Fix: The element renders blank during SSR and hydrates on the client — this is expected behavior. If hydration mismatches appear in React, render the element only after mount (move it inside the useEffect or use suppressHydrationWarning).
4. CORS blocked on data-api-base-url¶
Cause: The API Worker's allowed origins don't include your headless storefront domain.
Fix: Set CORS_ORIGINS in the Worker's environment variables to include your storefront origin.
5. data-bc-product-id not picked up¶
Cause: On non-BC headless storefronts, window.BCData is absent and the attribute must be explicit.
Fix: Always pass data-bc-product-id explicitly when not running on a BC storefront.
Next steps¶
- SDK reference — for building fully custom UI on top of the API (bypassing the web component entirely)
- Catalyst integration — if you're on Catalyst (Next.js App Router with BC GraphQL)
- ADR-0013 — component-shape rationale