Skip to content

Hydrogen / Oxygen Indirect Tracking

How it works

Indirect tracking connects a purchase on your Shopify store back to the Funneled funnel that influenced it — even when the customer leaves and comes back later through a different channel.

  1. A customer visits one of your funnel pages
  2. Funneled sets a tracking cookie on your root domain
  3. The customer returns to your Hydrogen store later (via Google, direct, email, etc.)
  4. The AttributionTracker component reads the cookie and writes attribution data to the Shopify cart
  5. When the customer checks out, Funneled records the conversion in your analytics

On standard theme stores, Funneled installs the listener automatically. On Hydrogen, add the following component to your app instead.


Prerequisites

Your storefront must share a root domain with your funnel subdomain — the _gf_attr cookie is scoped to it.

Funnel subdomainStore domainWorks?
pages.acme.comacme.comYes
pages.acme.comshop.acme.comYes
pages.acme.commystore.myshopify.comNo — different root domains

Implementation

1. Add AttributesUpdateInput to your cart action

In your existing app/routes/cart.tsx, add one case to the switch statement:

ts
// app/routes/cart.tsx
case CartForm.ACTIONS.AttributesUpdateInput:
  result = await cart.updateAttributes(inputs.attributes);
  break;

2. Attribution tracker component

tsx
// app/components/AttributionTracker.tsx

import {useEffect} from 'react';
import {useFetcher} from 'react-router';
import {CartForm} from '@shopify/hydrogen';

interface AttributionPayload {
  pid?: string;
  tid?: string;
  fid?: string;
  uid?: string;
  vid?: string;
  eid?: string;
  ts?: number;
}

function getCookie(name: string): string | null {
  const match = document.cookie.match(
    new RegExp('(?:^|; )' + name + '=([^;]*)'),
  );
  return match ? match[1] : null;
}

function payloadHash(p: AttributionPayload): string {
  return [p.pid, p.tid, p.fid, p.uid, p.vid, p.eid].join('|');
}

function sendAttribution(submit: ReturnType<typeof useFetcher>['submit']) {
  const TTL_MS = 30 * 24 * 60 * 60 * 1000;

  try {
    const raw = getCookie('_gf_attr');
    if (!raw) return;

    const p: AttributionPayload = JSON.parse(decodeURIComponent(raw));
    if (!p?.pid) return;

    if (p.ts && Date.now() - p.ts > TTL_MS) return;

    const applied = JSON.parse(localStorage.getItem('_gf_applied') || '{}');
    if (applied.hash === payloadHash(p)) return;

    // Mark before submitting — prevents duplicate sends on re-render or navigation
    localStorage.setItem('_gf_applied', JSON.stringify({hash: payloadHash(p)}));

    const attributes = [
      {key: '_gf_src', value: 'indirect'},
      p.pid && {key: '_gf_pid', value: p.pid},
      p.tid && {key: '_gf_sid', value: p.tid},
      p.fid && {key: '_gf_fid', value: p.fid},
      p.uid && {key: '_gf_uid', value: p.uid},
      p.vid && {key: '_gf_vid', value: p.vid},
      p.eid && {key: '_gf_eid', value: p.eid},
    ].filter(Boolean);

    submit(
      {
        [CartForm.INPUT_NAME]: JSON.stringify({
          action: CartForm.ACTIONS.AttributesUpdateInput,
          inputs: {attributes},
        }),
      },
      {method: 'POST', action: '/cart'},
    );
  } catch (_) {
    // Never surface attribution errors to the user
  }
}

export function AttributionTracker() {
  const {submit} = useFetcher();

  useEffect(() => {
    // Defer until idle — attribution never competes with rendering
    if (typeof requestIdleCallback !== 'undefined') {
      requestIdleCallback(() => sendAttribution(submit), {timeout: 2_000});
    } else {
      setTimeout(() => sendAttribution(submit), 0);
    }
  }, []);

  return null;
}

3. Mount inside App

Add <AttributionTracker /> inside the default App export (not Layout), where router and provider context is available:

tsx
// app/root.tsx
import {AttributionTracker} from '~/components/AttributionTracker';

export default function App() {
  const data = useRouteLoaderData<RootLoader>('root');
  return (
    <Analytics.Provider cart={data?.cart} shop={data?.shop} consent={data?.consent}>
      <PageLayout>
        <Outlet />
      </PageLayout>
      <AttributionTracker />
    </Analytics.Provider>
  );
}

Verify the integration

  1. Visit a funnel page on your subdomain.
  2. Navigate to your Hydrogen store and place a test order.
  3. In Shopify Admin → Orders, open the order and confirm Funneled attribution data appears in the order's note attributes.

If attribution isn't appearing, check that your funnel subdomain and store share the same root domain (see Prerequisites above).


Disable indirect tracking

Toggle off from Funneled → Settings → Attribution. The widget stops writing the tracking cookie and the component becomes a no-op.