Server-side tracking for a Hydrogen (headless) storefront. Hydrogen tracking is part of the Agency plan, or a custom plan.
PixelRelay tracks a Hydrogen store in three layers, and only the first needs code from you:
| Layer | Covers | Setup |
|---|---|---|
| Storefront events | page views, product views, add to cart, search | This guide |
| Checkout events | checkout started, payment info | Automatic (PixelRelay's Web Pixel in Shopify checkout) |
| Order webhook | the purchase | Automatic (server to server) |
Step 0. Get your token
- In the PixelRelay app, open Settings → Storefront and turn on My store uses Hydrogen (headless). On other plans the switch isn't there, and events return
402. - Open Storefront guide in the left menu. If it isn't there, use Finish Hydrogen setup on the Hydrogen event stream card on Home.
- Copy the token (Reveal, then Copy). It's your
PIXEL_RELAY_SECRET.
Step 1. Install the package
npm install @pixelrelay/hydrogen
Step 2. Add the proxy route
The route lives on your own domain and passes events to PixelRelay from your server, so ad blockers never see a tracking request.
npx pixelrelay-hydrogen install-route
This creates app/routes/api.relay.tsx, appends PIXEL_RELAY_SECRET= to your .env, and warns if your routes.ts wouldn't pick the file up.
Step 3. Allow the vendor tags in your CSP
Required. Skipping this fails silently.
Hydrogen's createContentSecurityPolicy() sets default-src to 'self', cdn.shopify.com and shopify.com, and no script-src. The browser then refuses every vendor tag PixelRelay injects (gtag.js, fbevents.js, bat.js, Reddit's pixel.js) before it runs. There's no console error. The script tag sits in the DOM with transferSize: 0, and window.google_tag_manager never appears. Server-side sending still works, so GA4 and Meta still look fine, which is why this usually goes unnoticed.
In app/entry.server.tsx:
import {pixelRelayCspDirectives} from '@pixelrelay/hydrogen';
const {nonce, header, NonceProvider} = createContentSecurityPolicy({
shop: {
checkoutDomain: context.env.PUBLIC_CHECKOUT_DOMAIN,
storeDomain: context.env.PUBLIC_STORE_DOMAIN,
},
...pixelRelayCspDirectives(),
});
Pass the platforms you've connected to keep the policy tight, for example pixelRelayCspDirectives(['ga4', 'meta']). The names are 'ga4', 'google_ads', 'meta', 'tiktok', 'klaviyo', 'snapchat', 'bing' and 'reddit'. Leave the argument out to allow all of them.
Don't add your own scriptSrc next to it. createContentSecurityPolicy() merges into directives it already defines, but builds script-src from only what you pass. Your scriptSrc would replace the script policy inherited from default-src, drop 'self', the nonce and cdn.shopify.com, and your storefront would stop hydrating.
Step 4. Set the token
Add it to .env and to your Oxygen environment:
PIXEL_RELAY_SECRET=your_per_shop_token
The relay URL ships inside the package. PIXEL_RELAY_URL is only for self-hosted or staging use. If the token leaks, use Rotate Token on the Storefront guide, then update the variable.
Step 5. Add the provider
In app/root.tsx, put <PixelRelayProvider> inside Hydrogen's <Analytics.Provider>. Keep the cart deferred: don't await context.cart.get() in the root loader. <PixelRelayCartAttributes> inside <Await> writes the attribution onto the cart without blocking first paint.
import { Suspense } from 'react';
import { Await, Outlet, useRouteLoaderData } from 'react-router';
import { Analytics } from '@shopify/hydrogen';
import { PixelRelayProvider, PixelRelayCartAttributes } from '@pixelrelay/hydrogen';
// Use the Storefront API version your project already uses.
const STOREFRONT_API_VERSION = '2026-04';
export default function App() {
const data = useRouteLoaderData<RootLoader>('root');
if (!data) return <Outlet />;
const storefrontApiUrl =
`https://${data.publicStoreDomain}/api/${STOREFRONT_API_VERSION}/graphql.json`;
return (
<Analytics.Provider cart={data.cart} shop={data.shop} consent={data.consent}>
<PixelRelayProvider
shop={data.publicStoreDomain}
proxyUrl="/api/relay"
storefrontApiUrl={storefrontApiUrl}
storefrontToken={data.consent.storefrontAccessToken}
>
<Suspense fallback={<PageLayout {...data}><Outlet /></PageLayout>}>
<Await resolve={data.cart}>
{(cart) => (
<>
<PixelRelayCartAttributes
cartId={cart?.id}
storefrontApiUrl={storefrontApiUrl}
storefrontToken={data.consent.storefrontAccessToken}
/>
<PageLayout {...data}>
<Outlet />
</PageLayout>
</>
)}
</Await>
</Suspense>
</PixelRelayProvider>
</Analytics.Provider>
);
}
The provider fetches your connected platform IDs and Google Ads labels through the same route, so you don't paste pixel IDs into code.
If you have logged-in customers, pass customer so Klaviyo can follow the shopper, and pass null on logout to reset it.
Page views need no per-page code. Once the provider is mounted, page_viewed fires on every route change, using a built-in useLocation fallback if Hydrogen's analytics bus is blocked or missing. PixelRelay only passes page views on to your platforms if Track page views server-side is on (Settings → Relay, Pro or higher). Browser tags fire either way.
Step 6. Add view tracking
On product pages:
import { Analytics } from '@shopify/hydrogen';
<Analytics.ProductView
data={{
products: [{
id: product.id,
title: product.title,
price: selectedVariant.price.amount,
vendor: product.vendor,
variantId: selectedVariant.id,
variantTitle: selectedVariant.title,
quantity: 1,
}],
}}
/>
Add <Analytics.CollectionView> on collection pages, <Analytics.SearchView> on search results and <Analytics.CartView> in your cart.
These come from Hydrogen's analytics bus, so an ad blocker that stalls the bus drops them. For blocker-proof product and search views, call useTrackEvent('product_viewed', ...) and useTrackEvent('search_submitted', ...) from those routes instead and remove the matching Hydrogen marker so they aren't sent twice. The package README has the details.
Step 7. Track add to cart
An ad blocker can quietly kill add to cart. Hydrogen only emits PRODUCT_ADD_TO_CART while its analytics bus is alive, and blockers stall the bus. Call useTrackAddToCart() from your button so it fires either way:
import { useTrackAddToCart } from '@pixelrelay/hydrogen';
export function ProductForm({ selectedVariant }) {
const trackAddToCart = useTrackAddToCart();
return (
<AddToCartButton
lines={[{ merchandiseId: selectedVariant.id, quantity: 1 }]}
onClick={() => trackAddToCart(selectedVariant, 1)}
>
Add to cart
</AddToCartButton>
);
}
Pass your Storefront API selectedVariant straight in. The hook suppresses the matching bus event, so unblocked visitors are counted once.
Step 8. Hand off to checkout
Await useTrackCheckout() before sending the shopper to the Shopify checkout URL:
import { useTrackCheckout } from '@pixelrelay/hydrogen';
function CheckoutLink({ cart }) {
const trackCheckout = useTrackCheckout();
const beginCheckout = async (event) => {
event.preventDefault();
try {
await trackCheckout(cart);
} finally {
window.location.assign(cart.checkoutUrl);
}
};
return <a href={cart.checkoutUrl} onClick={beginCheckout}>Checkout</a>;
}
This puts attribution and a short-lived signed token on the exact cart. It doesn't send a checkout conversion. PixelRelay's Web Pixel inside Shopify's checkout sends checkout_started and payment_info_submitted, and the order webhook sends the purchase.
Step 9. Verify
-
With your dev server running, run the check. It calls your proxy route with your token and confirms the plan allows Hydrogen:
PIXEL_RELAY_PROXY_URL=http://localhost:3000/api/relay npx pixelrelay-hydrogen-verify your-store.myshopify.comThe check defaults to
/api/pixel, so the variable is needed on a/api/relayinstall. -
Open your storefront with the Network tab open and click around. You should see
POST /api/relayreturning 204. -
Add a product to your cart. Add to cart should show up in Recent events on Home and in Reports → Event log. Page, product and cart views are hidden from those lists by default (Settings → Analytics display).
-
Check the vendor tags loaded. On a page where GA4 is connected,
window.google_tag_managershould be defined in the console. If it'sundefinedwhile/api/relayrequests are fine, your CSP from Step 3 is missing or missing a platform.
To test blockers, turn on uBlock Origin and click around again. Events should still arrive, because they go to your own domain.
Optional: override pixel IDs
The provider pulls your connected IDs automatically. Use scripts only to override them:
<PixelRelayProvider
shop={shop}
scripts={{
metaPixelId: '1234567890',
tiktokPixelCode: 'ABCDE12345',
ga4MeasurementId: 'G-XXXXXXXX',
snapchatPixelId: 'snap-pixel-id',
bingTagId: 'bing-tag-id',
redditPixelId: 'reddit-pixel-id',
}}
>
On stores set up through Guided setup, PixelRelay's connected IDs always win and scripts is ignored.
Optional: events from a loader or action
import { relayEvent } from '@pixelrelay/hydrogen/server';
export async function action({ request, context }: ActionFunctionArgs) {
await relayEvent({
shop: context.env.PUBLIC_STORE_DOMAIN,
eventName: 'product_added_to_cart',
payload: { cartLine: { merchandise: { /* id, product, price */ }, quantity: 1 } },
relaySecret: context.env.PIXEL_RELAY_SECRET,
clientIpAddress: request.headers.get('x-forwarded-for')?.split(',')[0],
});
}
Network errors return { ok: false, status: 0 } and never throw.
Troubleshooting
| Symptom | Fix |
|---|---|
No /api/relay requests | Check app/routes/api.relay.tsx exists, your routes.ts registers it (flatRoutes(), or route('/api/relay', 'routes/api.relay.tsx')), and proxyUrl="/api/relay" is on the provider |
500 from the route | PIXEL_RELAY_SECRET is missing from the server environment |
401 | The token is wrong or was rotated |
402 | The plan is inactive or doesn't include Hydrogen (Agency or a custom plan does) |
429 | The monthly event limit for your plan is reached |
| Events arrive but no attribution cookies | The CSP in Step 3 is missing, so vendor tags never ran |
window.google_tag_manager undefined, script tag present at transferSize: 0 | Same cause. Add pixelRelayCspDirectives() in entry.server.tsx |
| Page views not showing in PixelRelay | Track page views server-side is off. Or the provider isn't mounted in root.tsx, or the console shows an error |
Need help? Email [email protected]