npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-websdk

Contents


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 tag
  • https://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, partnerId or nonce changes 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:

  • frameSrchttps://<partnerName>.api.useinsider.com, for Insider-rendered overlays
  • styleSrc — 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:

  • id is the variant, groupcode is the product. Insider groups variants of the same product by groupcode.
  • Strip the Shopify GID prefix from product and variant ids, so they match the ids in your catalogue feed.
  • unit_price is the pre-discount price. Use compareAtPrice when present, falling back to price.

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 → init

init 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 user on 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 pushOtherPageView unless 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

  1. The tag loaded. In DevTools → Network, look for ins.js?id=<partnerId> and multi-domain-helper.js, both 200. A blocked request with a CSP error in the console means step 2 is incomplete.
  2. The queue is filling. Run window.InsiderQueue in the console. You should see the ordered items for the current page, ending with {type: 'init'}.
  3. The order is right. For a product page: usercurrencycartproductinit.
  4. 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.
  5. Identity carries into checkout. Click through to cart.checkoutUrl and confirm the Insider cookie survives the domain hop.

Known gaps

Worth knowing before you hit them:

  • Domain types are not exported. Product, Cart, CartItem, UserAttributes and 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.
  • Product has no groupcode field, 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.custom is typed Record<string, object>, which rejects the string values that selected-option mapping naturally produces. CustomAttributes (used for page-view custom) has the more accurate string | number | boolean | null.
  • react and react-dom are declared as dependencies, 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