How do you show the Subi subscription widget on a storefront that has no Liquid theme, such as Hydrogen, Next.js or another headless front end? You load the Subi SDK on your product page, mount the widget into an element you own, listen for the plan your customer picks, and put that plan on the cart line. This page walks through each step with Hydrogen code. Other React front ends follow the same steps.
What works on a headless storefront
The headless widget is the product-page subscription selector. It shows the same plans, prices, skin and wording that your Liquid theme shows for the same product, because it reads the same Subi settings.
These Subi features still need a Liquid theme: build-a-box, fixed bundles, memberships, volume discounts and the Subi cart drawer. On a headless page the SDK does not touch your cart, your forms or your page's network calls. Your own code adds to cart, as shown in step 5.
Before you start
Subi is installed on the store, the subscription widget is set up in Subi, and the product has a Subi selling plan. If the widget shows on the product in your Liquid theme, you are ready.
The product is published to your headless storefront's sales channel (Hydrogen or Headless) in Shopify admin, and the channel's Storefront API permissions include selling plans. Without that permission, the product comes back with no plans.
You know the store's myshopify domain, for example
your-store.myshopify.com.
Step 1: Allow Subi in your Content Security Policy
Hydrogen sends a strict Content Security Policy, which blocks the Subi SDK until you allow its two origins: https://cdn.subi.co serves the SDK script and its assets, and https://api.subi.co serves your plan and widget settings. In app/entry.server.tsx, add them to createContentSecurityPolicy:
const {nonce, header, NonceProvider} = createContentSecurityPolicy({
scriptSrc: ["'self'", 'https://cdn.shopify.com', 'https://cdn.subi.co'],
connectSrc: ['https://api.subi.co', 'https://cdn.subi.co'],
imgSrc: ["'self'", 'data:', 'https://cdn.shopify.com', 'https://cdn.subi.co'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://cdn.shopify.com', 'https://cdn.subi.co'],
shop: {
checkoutDomain: context.env.PUBLIC_CHECKOUT_DOMAIN,
storeDomain: context.env.PUBLIC_STORE_DOMAIN,
},
});
If the widget does not appear, check your browser console for a "Refused to load" message naming one of these origins.
Step 2: Query the product fields the widget needs
The widget builds itself from the product as the Storefront API returns it, including its selling plan groups and each variant's selling plan allocations. Add this fragment to your product route (app/routes/products.$handle.tsx) and query it next to your own product fields:
const SUBI_PRODUCT_FRAGMENT = `#graphql
fragment SubiProduct on Product {
id
title
handle
featuredImage { id url altText }
collections(first: 10) { nodes { id title } }
sellingPlanGroups(first: 20) {
nodes {
appName
name
options { name values }
sellingPlans(first: 20) {
nodes {
id
name
description
recurringDeliveries
options { name value }
priceAdjustments {
orderCount
adjustmentValue {
... on SellingPlanPercentagePriceAdjustment { adjustmentPercentage }
... on SellingPlanFixedAmountPriceAdjustment { adjustmentAmount { amount currencyCode } }
... on SellingPlanFixedPriceAdjustment { price { amount currencyCode } }
}
}
}
}
}
}
variants(first: 50) {
nodes {
id
title
availableForSale
selectedOptions { name value }
image { id url }
price { amount currencyCode }
compareAtPrice { amount currencyCode }
sellingPlanAllocations(first: 20) {
nodes {
sellingPlan { id }
priceAdjustments {
compareAtPrice { amount currencyCode }
perDeliveryPrice { amount currencyCode }
price { amount currencyCode }
unitPrice { amount currencyCode }
}
}
}
}
}
}
` as const;
In the route's product query, add a second, aliased product field that uses it, and include ${SUBI_PRODUCT_FRAGMENT} in the query string:
subiProduct: product(handle: $handle) {
...SubiProduct
}
Return subiProduct from your loader next to product, and throw a 404 when it is missing, as the skeleton already does for product. Pass the whole object to the widget as it came back from the API, without reshaping it.
Step 3: Load the SDK and mount the widget
Create app/components/SubiWidget.tsx. It loads the Subi SDK once from https://cdn.subi.co/subify/v5/subify-sdk.js. It then calls window.subifySdk.mountHeadlessWidget with three things: your store's myshopify domain, the product from step 2, and an element to render into. The call returns a function that removes the widget, so call it when the component unmounts.
import {useEffect, useRef} from 'react';const SUBI_SDK_URL = 'https://cdn.subi.co/subify/v5/subify-sdk.js';
const SHOP = 'your-store.myshopify.com';type SubifySdk = {
mountHeadlessWidget: (options: {
shop: string;
product: unknown;
mount: HTMLElement;
locale?: string;
variantId?: string;
}) => Promise<() => void>;
changeVariant: (productId: string, variantId: string) => void;
};declare global {
interface Window {
subifySdk?: SubifySdk;
}
}let sdkPromise: Promise<SubifySdk> | null = null;function loadSubifySdk(): Promise<SubifySdk> {
if (sdkPromise) return sdkPromise;
sdkPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = SUBI_SDK_URL;
script.async = true;
script.onerror = () => reject(new Error('Subi SDK failed to load'));
document.head.appendChild(script);
const started = Date.now();
const poll = () => {
if (window.subifySdk?.mountHeadlessWidget) return resolve(window.subifySdk);
if (Date.now() - started > 20000) return reject(new Error('Subi SDK not ready'));
setTimeout(poll, 50);
};
poll();
});
return sdkPromise;
}const numericId = (gid: string) => Number(gid.split('/').pop());export function SubiWidget({
product,
selectedVariantId,
onSellingPlanChange,
}: {
product: {id: string};
selectedVariantId?: string;
onSellingPlanChange: (sellingPlanId: string | null) => void;
}) {
const mountRef = useRef<HTMLDivElement>(null);
const variantRef = useRef(selectedVariantId);
variantRef.current = selectedVariantId; useEffect(() => {
let destroy: (() => void) | undefined;
let cancelled = false;
loadSubifySdk()
.then((sdk) =>
sdk.mountHeadlessWidget({
shop: SHOP,
product,
mount: mountRef.current!,
variantId: variantRef.current,
}),
)
.then((remove) => {
if (cancelled) remove();
else destroy = remove;
})
.catch((error) => console.error(error));
return () => {
cancelled = true;
destroy?.();
};
}, [product.id]); useEffect(() => {
if (selectedVariantId && window.subifySdk) {
window.subifySdk.changeVariant(product.id, selectedVariantId);
}
}, [product.id, selectedVariantId]); useEffect(() => {
const onChange = (event: Event) => {
const detail = (event as CustomEvent).detail;
if (detail?.targetProductId !== numericId(product.id)) return;
onSellingPlanChange(detail.sellingPlanGid ?? null);
};
window.addEventListener('subify:sellingPlanChange', onChange);
return () => window.removeEventListener('subify:sellingPlanChange', onChange);
}, [product.id, onSellingPlanChange]); return <div ref={mountRef} />;
}
Replace your-store.myshopify.com with your store's myshopify domain. Passing the PUBLIC_STORE_DOMAIN value your app already has, through your loader, works too. To show a translation you have set up in Subi, pass locale as well, for example 'fr'. Without it, the widget shows the wording as you wrote it in Subi.
Step 4: Keep the widget on the selected variant
Your page owns the variant picker, so tell the widget when the customer picks another variant. The component above does this by calling window.subifySdk.changeVariant(product.id, variant.id). It accepts the Storefront API ids exactly as you have them (gid://shopify/Product/… and gid://shopify/ProductVariant/…). The widget then shows that variant's plans and prices.
Step 5: Put the chosen plan on the cart line
When the customer picks a plan, the SDK fires a subify:sellingPlanChange event on window. Its detail.sellingPlanGid is the plan id in the form the Storefront API cart expects, for example gid://shopify/SellingPlan/3190456383. For a one-time purchase it is null. Pass it to the cart line unchanged.
In your product route, keep the chosen plan in state and render the widget next to the add-to-cart form:
const [sellingPlanId, setSellingPlanId] = useState<string | null>(null);
const onSellingPlanChange = useCallback((id: string | null) => setSellingPlanId(id), []);<SubiWidget
product={subiProduct}
selectedVariantId={selectedVariant?.id}
onSellingPlanChange={onSellingPlanChange}
/>
<ProductForm
productOptions={productOptions}
selectedVariant={selectedVariant}
sellingPlanId={sellingPlanId}
/>
In app/components/ProductForm.tsx, accept the sellingPlanId prop and add it to the line you pass to AddToCartButton:
lines={
selectedVariant
? [
{
merchandiseId: selectedVariant.id,
quantity: 1,
...(sellingPlanId ? {sellingPlanId} : {}),
selectedVariant,
},
]
: []
}
The cart line now carries the subscription. Checkout creates the subscription, and it appears in Subi like any other subscription order.
The skeleton's cart does not show a line's plan, so show it to your shoppers. Add sellingPlanAllocation { sellingPlan { name } } to the cart line fragments in app/lib/fragments.ts, then render line.sellingPlanAllocation?.sellingPlan.name in app/components/CartLineItem.tsx.
The widget shows each plan's price itself, but the SDK never changes your page's own price. If your product page prints a price next to the widget, hide it, or update it when a plan is chosen, so shoppers don't see two different prices.
Step 6: Let subscribers manage their subscriptions
On a headless storefront, subscribers manage their subscriptions in Subi's Customer Account Extension, which runs inside Shopify's New Customer Accounts. It does not run inside Hydrogen's own account pages. To set it up, follow What is the Customer Account Extension (Embedded Portal)?. Once it is on, the portal links in Subi's emails take subscribers straight there. To give them a way in from your storefront too, link your account menu to your store's Shopify customer account.
Troubleshooting
No widget appears. Check the browser console for a Content Security Policy error (step 1). Also check that the product has a Subi plan and is published to your headless sales channel.
The widget shows, but the cart line has no plan. Check that you pass
detail.sellingPlanGid(notselectedSellingPlan.id) assellingPlanId.Prices don't change with the variant. Check that your variant picker calls
changeVariantwith the new variant id (step 4).
For theme-based storefronts and the SDK's other methods and events, see Subi JavaScript SDK Documentation for Developers.
