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.
- A customer visits one of your funnel pages
- Funneled sets a tracking cookie on your root domain
- The customer returns to your Hydrogen store later (via Google, direct, email, etc.)
- The
AttributionTrackercomponent reads the cookie and writes attribution data to the Shopify cart - 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 subdomain | Store domain | Works? |
|---|---|---|
pages.acme.com | acme.com | Yes |
pages.acme.com | shop.acme.com | Yes |
pages.acme.com | mystore.myshopify.com | No — 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:
// app/routes/cart.tsx
case CartForm.ACTIONS.AttributesUpdateInput:
result = await cart.updateAttributes(inputs.attributes);
break;2. Attribution tracker component
// 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:
// 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
- Visit a funnel page on your subdomain.
- Navigate to your Hydrogen store and place a test order.
- 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.
