@useinsider/hydrogen-websdk
v1.0.1
Published
React bindings for the [Insider Web SDK](https://academy.insiderone.com/docs/insider-web-sdk-integration-guide), built for Shopify Hydrogen storefronts.
Readme
@useinsider/hydrogen-websdk
React bindings for the Insider Web SDK, built for Shopify Hydrogen storefronts.
The package loads the Insider tag, owns the window.InsiderQueue the tag drains,
and gives you typed helpers for pushing page views and cart events onto it.
npm install @useinsider/hydrogen-websdkContents
- Requirements
- 1. Mount the provider
- 2. Allow Insider in your CSP
- 3. Map Shopify data to Insider shapes
- 4. Fire page views, page by page
- 5. Add to cart and remove from cart
- Data shapes
- API reference
- Verifying the integration
- Known gaps
Requirements
- React 18 or 19
- A Shopify Hydrogen storefront (Remix-based)
- An Insider account, with your partner name and partner ID
1. Mount the provider
Insider.Provider injects the Insider tag and provides the context every hook
and static method reads. Mount it once, as high in the tree as possible —
inside <body> in root.tsx, wrapping everything that might fire an event.
// app/root.tsx
import {Insider} from '@useinsider/hydrogen-websdk';
import {useNonce} from '@shopify/hydrogen';
export function Layout({children}: {children: React.ReactNode}) {
const nonce = useNonce();
return (
<html lang="en">
<head>{/* … */}</head>
<body>
<Insider.Provider
partnerName="your-partner-name"
partnerId="10012316"
nonce={nonce || ''}
>
{children}
</Insider.Provider>
<ScrollRestoration nonce={nonce} />
<Scripts nonce={nonce} />
</body>
</html>
);
}| Prop | Type | Notes |
| --- | --- | --- |
| partnerName | string | Your Insider partner name. Becomes the tag host: https://<partnerName>.api.useinsider.com |
| partnerId | string | Your Insider partner ID. Missing or empty logs an error and skips tag injection. |
| nonce | string | Hydrogen's CSP nonce, from useNonce(). Required — Hydrogen ships a CSP by default and unnonced scripts are blocked. |
On mount the provider appends two scripts to document.head:
https://<partnerName>.api.useinsider.com/ins.js?id=<partnerId>— the Insider taghttps://shopify-pixel.useinsider.com/multi-domain-helper.js— carries Insider identity across the storefront → Shopify checkout domain hop
Keep the props stable. The provider re-injects both scripts whenever
partnerName,partnerIdornoncechanges identity, and it never removes the previous tags. Pass literals or values from a stable source, not freshly-computed objects.
Mount the provider in your ErrorBoundary layout too, if you want events on
error pages.
2. Allow Insider in your CSP
Hydrogen builds a Content-Security-Policy in app/entry.server.tsx. Insider
origins must be allowed or the tag never loads.
Use the helper so you do not hardcode hosts:
// app/entry.server.tsx
import {createContentSecurityPolicy} from '@shopify/hydrogen';
import {getInsiderCspSources} from '@useinsider/hydrogen-websdk';
const insider = getInsiderCspSources('your-partner-name');
const {nonce, header, NonceProvider} = createContentSecurityPolicy({
scriptSrc: ["'self'", ...insider.scriptSrc],
connectSrc: ["'self'", ...insider.connectSrc],
defaultSrc: ["'self'", ...insider.scriptSrc],
});getInsiderCspSources(partnerName) returns:
{
scriptSrc: ['https://<partnerName>.api.useinsider.com', 'https://shopify-pixel.useinsider.com'],
connectSrc: ['https://<partnerName>.api.useinsider.com', 'https://shopify-pixel.useinsider.com'],
}Depending on which Insider products you use, you may also need:
frameSrc—https://<partnerName>.api.useinsider.com, for Insider-rendered overlaysstyleSrc— the same origins, for injected campaign styles
3. Map Shopify data to Insider shapes
This package does not transform Shopify data. You pass it Product and
Cart objects in Insider's shape, so put the mapping in one module and reuse it
everywhere — otherwise the same product ends up with different ids on the
product page and in the cart, and Insider cannot join them.
// app/lib/insider.ts
function toNumber(amount?: string | null) {
const parsed = Number.parseFloat(amount || '0');
return Number.isFinite(parsed) ? parsed : 0;
}
// 'gid://shopify/Product/123' -> '123'
function stripGid(id?: string | null, type?: string) {
if (!id) return '';
if (!type) return id;
const prefix = `gid://shopify/${type}/`;
return id.startsWith(prefix) ? id.slice(prefix.length) : id;
}
export function buildInsiderItemFromVariant({
variant, storeDomain, quantity, productId, productHandle, productTitle, productCollections,
}) {
if (!variant) return null;
return {
id: stripGid(variant.id, 'ProductVariant'),
groupcode: stripGid(productId, 'Product'),
name: productTitle || variant.title || '',
taxonomy: (productCollections?.nodes || [])
.map((node) => (node?.title || '').trim())
.filter(Boolean),
// Insider expects the ORIGINAL price in unit_price and the price actually
// paid in unit_sale_price. Swapping these breaks discount reporting.
unit_price: toNumber(variant.compareAtPrice?.amount || variant.price?.amount),
unit_sale_price: toNumber(variant.price?.amount),
url: `${storeDomain}/products/${productHandle}`,
stock: variant.availableForSale ? 1 : 0,
color: getSelectedOptionValue(variant.selectedOptions, isColorOptionName),
size: getSelectedOptionValue(variant.selectedOptions, isSizeOptionName),
product_image_url: variant.image?.url || '',
custom: buildCustomOptions(variant.selectedOptions),
quantity,
};
}
export function buildInsiderCart(cart, storeDomain) {
if (!cart) return undefined;
const total = toNumber(cart.cost?.totalAmount?.amount);
const subtotal = toNumber(cart.cost?.subtotalAmount?.amount);
const tax = toNumber(cart.cost?.totalTaxAmount?.amount);
const duty = toNumber(cart.cost?.totalDutyAmount?.amount);
return {
total,
// Shopify has no shipping field on the cart — derive it.
shipping_cost: Math.max(0, total - subtotal - tax - duty),
items: (cart.lines?.nodes || [])
.map((line) => buildInsiderItemFromCartLine(line, storeDomain))
.filter(Boolean),
};
}
export function buildInsiderUser(cart) {
const buyerIdentity = cart?.buyerIdentity;
const customer = buyerIdentity?.customer;
const email = customer?.email || buyerIdentity?.email;
const phone = buyerIdentity?.phone;
if (!email && !phone && !customer?.displayName) return undefined;
return {
email,
phone,
firstName: customer?.firstName,
lastName: customer?.lastName,
displayName: customer?.displayName,
customerId: customer?.id,
};
}Three conventions worth copying:
idis the variant,groupcodeis the product. Insider groups variants of the same product bygroupcode.- Strip the Shopify GID prefix from product and variant ids, so they match the ids in your catalogue feed.
unit_priceis the pre-discount price. UsecompareAtPricewhen present, falling back toprice.
Your cart GraphQL fragment needs compareAtPrice, availableForSale,
selectedOptions, product { id handle title collections }, the full cost
block, plus updatedAt and totalQuantity (you will need those for deduping).
4. Fire page views, page by page
Every page-view helper pushes items in a fixed order, ending with init:
user (if given) → currency (if given) → cart (if given) → page data → initinit tells the Insider tag the page context is complete. A page view without
init is silently inert, which is why you should use these helpers rather than
assembling pushToQueue calls by hand.
All examples use the useInsider() hook. Fire from useEffect, not from the
component body — during SSR there is no window and events are dropped, not
buffered.
A note on deduping
Hydrogen re-renders routes on cart mutations, navigation and revalidation. Without a guard you will fire the same page view several times. The pattern used throughout is a tracking key held in a ref:
const lastTrackedRef = useRef<string>('');
const trackingKey = useMemo(() => {
if (!cart?.id) return 'home';
return `${cart.id}-${cart.updatedAt || ''}-${cart.totalQuantity || 0}`;
}, [cart?.id, cart?.updatedAt, cart?.totalQuantity]);
useEffect(() => {
if (lastTrackedRef.current === trackingKey) return;
lastTrackedRef.current = trackingKey;
// … push here
}, [trackingKey /* … */]);Home
import {useInsider} from '@useinsider/hydrogen-websdk';
function HomeTracking({cart, storeDomain}) {
const {pushHomePageView} = useInsider();
const lastTrackedRef = useRef<string>('');
const insiderCart = useMemo(() => buildInsiderCart(cart, storeDomain), [cart, storeDomain]);
const insiderUser = useMemo(() => buildInsiderUser(cart), [cart]);
const trackingKey = useMemo(
() => (cart?.id ? `${cart.id}-${cart.updatedAt || ''}-${cart.totalQuantity || 0}` : 'home'),
[cart?.id, cart?.updatedAt, cart?.totalQuantity],
);
useEffect(() => {
if (lastTrackedRef.current === trackingKey) return;
lastTrackedRef.current = trackingKey;
pushHomePageView({
currency: cart?.cost?.totalAmount?.currencyCode?.toString() || 'USD',
cart: insiderCart,
user: insiderUser,
});
}, [trackingKey, insiderCart, insiderUser, pushHomePageView]);
return null;
}Mount it behind a client-only gate so it never runs during hydration:
<ClientOnly>
<HomeTracking cart={rootCart} storeDomain={publicStoreDomain} />
</ClientOnly>Queue result: user, currency, cart, home, init.
Collection / category
Insider expects a breadcrumb array, outermost category first. A bare string is accepted and wrapped into a single-element array for you.
const {pushCategoryPageView} = useInsider();
useEffect(() => {
if (lastTrackedRef.current === trackingKey) return;
lastTrackedRef.current = trackingKey;
pushCategoryPageView({
// Strip any HTML from the collection title before sending it.
category: [collection.title.replace(/(<([^>]+)>)/gi, '')],
currency,
cart: insiderCart,
user: insiderUser,
});
}, [trackingKey, /* … */]);For a nested hierarchy, send the full path:
category: ['Dresses', 'Night Dresses', 'Long Sleeve'].
Queue result: user, currency, cart, category, init.
Product
Key the effect on product and variant, so switching variant re-fires the page view with the new price and image.
const {pushProductPageView} = useInsider();
const trackingKey = `${product.id}-${selectedVariant?.id}`;
useEffect(() => {
if (!insiderProduct || lastTrackedRef.current === trackingKey) return;
lastTrackedRef.current = trackingKey;
pushProductPageView({
product: insiderProduct,
// On a PDP take the currency from the variant, not the cart —
// the cart may be empty.
currency:
selectedVariant?.compareAtPrice?.currencyCode?.toString() ||
selectedVariant?.price?.currencyCode?.toString() ||
'USD',
cart: insiderCart,
user: insiderUser,
});
}, [trackingKey, insiderProduct, /* … */]);The product object for a page view carries no quantity — that field belongs
only to cart items and add/remove events.
Queue result: user, currency, cart, product, init.
Cart
Two things make the cart page the trickiest one.
1. The cart is usually rendered twice — a /cart route and a drawer. If your
/cart route auto-opens the drawer, both will try to fire. Guard with a
module-level dedupe shared by both:
// app/lib/insider.ts
let lastCartTrackingKey = '';
export function shouldTrackCartPageView(key: string) {
if (!key) return false;
if (lastCartTrackingKey === key) return false;
lastCartTrackingKey = key;
return true;
}2. Cart state settles asynchronously. Optimistic updates, the mutation response and revalidation each produce a render. Debounce so you report the settled cart, and keep the latest values in refs so the timer fires with fresh data rather than what was captured when it started:
const {pushCartPageView} = useInsider();
const CART_TRACK_DEBOUNCE_MS = 800;
useEffect(() => {
if (!cart?.id || !insiderCart) return;
if (lastTrackedRef.current === trackingKey) return;
latestKeyRef.current = trackingKey;
latestCartRef.current = insiderCart;
latestUserRef.current = insiderUser;
latestCurrencyRef.current = cart?.cost?.totalAmount?.currencyCode?.toString() || 'USD';
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
const key = latestKeyRef.current;
if (!shouldTrackCartPageView(key)) return; // the shared guard
lastTrackedRef.current = key;
pushCartPageView({
cart: latestCartRef.current,
currency: latestCurrencyRef.current,
user: latestUserRef.current ?? undefined,
});
}, CART_TRACK_DEBOUNCE_MS);
}, [trackingKey, insiderCart, insiderUser /* … */]);
useEffect(() => () => clearTimeout(timerRef.current), []);pushCartPageView takes {cart, currency?, user?} only — no product, no
category. Pass undefined rather than null for an absent user.
Queue result: user, currency, cart, init — the cart is pushed once,
not twice.
Restoring context when a drawer closes. If your cart is a drawer over another
page, closing it should return Insider to the underlying page's context.
Store the last non-cart page view and replay it with pushPageViewEvent:
const {pushPageViewEvent} = useInsider();
// after every non-cart page view:
setLastNonCartPageView({type: 'home', currency, cart: insiderCart, user: insiderUser});
// when the drawer closes on the same URL it was opened from:
const lastEvent = getLastNonCartPageView();
if (lastEvent) {
pushPageViewEvent({...lastEvent, currency, cart: insiderCart, user: insiderUser});
}Compare the URL at open time with the URL at close time — if the customer navigated while the drawer was open, skip the replay; the new route fires its own page view.
Every other page
Search, blog, policies, account, static pages — anything without a dedicated type
goes through pushOtherPageView. One tracker component mounted in your layout
covers them all:
const {pushOtherPageView} = useInsider();
const {pathname, search} = useLocation();
const isExcluded =
pathname === '/' ||
pathname.startsWith('/products/') ||
pathname.startsWith('/collections/') ||
pathname.startsWith('/cart');
useEffect(() => {
if (isExcluded) return; // those have their own trackers
if (lastTrackedRef.current === trackingKey) return;
lastTrackedRef.current = trackingKey;
pushOtherPageView({
name: pathname || '/',
custom: {path: pathname, search: search || ''},
currency,
cart: insiderCart,
user: insiderUser,
});
}, [trackingKey /* … */]);Exclude the paths that have their own trackers, or those pages will report twice.
custom accepts flat string | number | boolean | null values.
Queue result: user, currency, cart, other, init.
Checkout
Shopify checkout runs on a Shopify-owned domain, outside your Hydrogen app.
The provider never mounts there, so this package cannot — and does not — emit a
checkout page view. There is no 'checkout' page type.
Continuity across that boundary is handled by multi-domain-helper.js, which the
provider injects automatically. It keeps the Insider identity attached as the
customer moves from your storefront to checkout.shopify.com, so the session is
not split in two. Purchase and order-confirmation events come from Insider's
Shopify-side integration, not from this package.
What you should do on your side:
- Make sure the customer's identity is already known before they leave — pass
useron your cart page view, so email/phone are attached to the session. - Make sure the cart page view fires with the settled cart before the customer
clicks through to
cart.checkoutUrl. - Do not try to synthesise a checkout event with
pushOtherPageViewunless your Insider account manager has asked for it; it will not be interpreted as a checkout step.
5. Add to cart and remove from cart
These are not page views. They carry no init, they push a single item, and
that item must include quantity.
Fire them in the click handler, next to the cart mutation:
const {pushAddToCart} = useInsider();
<AddToCartButton
lines={[{merchandiseId: selectedVariant.id, quantity, selectedVariant}]}
onClick={() => {
if (insiderItem) pushAddToCart(insiderItem);
open('cart');
}}
>
Add to cart
</AddToCartButton>const {pushRemoveFromCart} = useInsider();
<button
type="submit"
onClick={() => {
const item = buildInsiderItemFromCartLine(line, storeDomain);
if (item) pushRemoveFromCart(item);
}}
>
Remove
</button>quantity is the quantity being added or removed, not the resulting line
quantity. Line-quantity edits are not modelled — send a remove followed by an add
if you need to represent one.
Data shapes
interface Product {
id: string; // variant id, GID stripped
name: string;
taxonomy: string[]; // collection titles, outermost first
unit_price: number; // original price (compareAtPrice when discounted)
unit_sale_price: number; // price actually paid
url: string; // absolute product URL
product_image_url: string;
stock?: number; // 1 available, 0 not
color?: string;
size?: string;
custom?: Record<string, object>;
}
interface CartItem extends Product {
quantity: number;
}
interface Cart {
total: number;
shipping_cost: number;
items: CartItem[];
}
type UserAttributes = Record<string, unknown>;
// commonly: {email, phone, firstName, lastName, displayName, customerId}
type CustomAttributes = Record<string, string | number | boolean | null>;API reference
Exports
import {
Insider, // {Provider, …static methods}
useInsider, // hook
getInsiderCspSources, // (partnerName: string) => InsiderCspSources
} from '@useinsider/hydrogen-websdk';useInsider()
Returns all actions below. Throws "useInsider must be used within an
InsiderProvider" when called outside the provider.
| Method | Signature |
| --- | --- |
| pushHomePageView | (params?: {currency?, cart?, user?}) => void |
| pushCategoryPageView | (params: {category: string \| string[], currency?, cart?, user?}) => void |
| pushProductPageView | (params: {product: Product, currency?, cart?, user?}) => void |
| pushCartPageView | (params: {cart: Cart, currency?, user?}) => void |
| pushOtherPageView | (params?: {name?, custom?, currency?, cart?, user?}) => void |
| pushPageViewEvent | (params: PageViewEventParams) => void |
| pushAddToCart | (item: CartItem) => void |
| pushRemoveFromCart | (item: CartItem) => void |
| pushProduct | (product: Product) => void |
| pushCart | (cart: Cart) => void |
| pushCurrency | (currency: string) => void |
| pushToQueue | (item: InsiderQueueItem) => void |
| pushInitToQueue | () => void |
| initialize | () => void |
pushPageViewEvent is the generic form the helpers delegate to:
pushPageViewEvent({
type: 'home' | 'category' | 'product' | 'cart' | 'other',
user?: UserAttributes,
currency?: string,
cart?: Cart,
product?: Product, // required when type is 'product'
category?: string | string[], // required when type is 'category'
name?: string, // optional, for type 'other'
custom?: CustomAttributes,
});Missing product for type: 'product', or missing cart for type: 'cart',
logs a console.error and drops the whole page view — no init is pushed.
Static Insider.*
The same methods are available without a hook, for call sites outside the component tree:
Insider.pushHomePageView({currency: 'USD'});
Insider.pushAddToCart({...item, quantity: 1});They no-op during SSR and throw if the provider has not mounted yet. Prefer
useInsider() inside components; reach for the static form only when a hook is
not available.
Verifying the integration
- The tag loaded. In DevTools → Network, look for
ins.js?id=<partnerId>andmulti-domain-helper.js, both200. A blocked request with a CSP error in the console means step 2 is incomplete. - The queue is filling. Run
window.InsiderQueuein the console. You should see the ordered items for the current page, ending with{type: 'init'}. - The order is right. For a product page:
user→currency→cart→product→init. - No duplicates. Navigate away and back, add an item to the cart, open and close the drawer. A second identical page view means your tracking key is not discriminating enough.
- Identity carries into checkout. Click through to
cart.checkoutUrland confirm the Insider cookie survives the domain hop.
Known gaps
Worth knowing before you hit them:
- Domain types are not exported.
Product,Cart,CartItem,UserAttributesand friends are not re-exported from the package entry point. To type your mapping functions you currently have to deep-import@useinsider/hydrogen-websdk/dist/types. Producthas nogroupcodefield, although Insider uses it to group variants of a product and the reference storefront sends it. You will need a cast until the type is widened.Product.customis typedRecord<string, object>, which rejects thestringvalues that selected-option mapping naturally produces.CustomAttributes(used for page-viewcustom) has the more accuratestring | number | boolean | null.reactandreact-domare declared asdependencies, not peers. If your bundler does not dedupe them you can end up with two React copies.- Events fired during SSR are dropped, not buffered. Always push from an effect or an event handler.
Links
- Insider Web SDK integration guide
- For partner name, partner ID, and anything about how your events are used in campaigns, talk to your Insider account manager.
