Add analytics and consent
Shopify analytics powers the reports in the Shopify admin, like sessions, conversion, and top products. Hydrogen sends the same events that Liquid themes send, and respects each visitor's privacy choices. This guide shows you how to load Shopify's runtime scripts, publish page and resource events, track cart changes, and handle consent.
Anchor to How it worksHow it works
Hydrogen's analytics has three parts:
ShopifyScriptsloads Shopify's Customer Privacy API, the privacy banner, and Shopify's analytics destination. It also creates one analytics bus for the page atwindow.Shopify.analytics.- The analytics bus receives the events that your app publishes, holds them until the visitor's consent allows delivery, and then delivers them to destinations.
- Destinations receive events from the bus. Shopify analytics is a destination that ships by default, and you can add your own.
Your app decides when to publish events, because only your routes know when a product or collection is on screen. The bus runs only in the browser. There's no server-side dispatch.
Anchor to RequirementsRequirements
- Wire request handlers, including the Storefront API proxy. Analytics reads tracking values from Storefront API responses and relies on the cookies that the proxy sets. Without the proxy, analytics and checkout session continuity are incomplete.
- Set the
SHOP_IDenvironment variable to your numeric shop ID. To attribute traffic to a Hydrogen storefront, also setPUBLIC_STOREFRONT_ID. - Build a cart with the cart store, if you want cart events.
Anchor to Step 1: Render ,[object Object]Step 1: Render ShopifyScripts
ShopifyScriptsRender ShopifyScripts once, in the document head of your root layout. Resolve the shop and i18n values on the server, and use the same market that your Storefront API requests use:
| Prop | Description |
|---|---|
shop | Your shop's shopId, storefrontId, and permanent myshopifyDomain. Use "0" for storefrontId if you don't have one. |
i18n | The active market's country and language, with an optional currency. |
consent | How Hydrogen collects consent. Refer to Step 5. |
navigate | Your router's navigate function, so Shopify scripts can navigate without a full page reload. |
routes | Your route templates, so Shopify scripts can build storefront URLs. |
analytics | Optional bus settings, like channel or customData. |
Render ShopifyScripts
lib/shopify-scripts.ts
Vanilla JavaScript
import {
initializeShopifyScripts,
renderShopifyScriptTags,
type ShopifyScriptsI18n,
} from '@shopify/hydrogen';
import {analyticsConsent, shop} from './config';
import {routeTemplates} from './route-templates';
// Server: include these HTML strings in the document head.
export function renderShopifyHead(i18n: ShopifyScriptsI18n): string {
return renderShopifyScriptTags({i18n, shop, consent: analyticsConsent}).join('');
}
// Browser: run once during startup.
export function startShopifyScripts(navigate: (url: string) => void) {
void initializeShopifyScripts({
routes: routeTemplates,
navigate,
consent: analyticsConsent,
});
}React
import {ShopifyScripts, type ShopifyScriptsProps} from '@shopify/hydrogen/react';
import type {ReactNode} from 'react';
import {analyticsConsent} from '../lib/config';
import {routeTemplates} from '../lib/route-templates';
type DocumentProps = Pick<ShopifyScriptsProps, 'shop' | 'i18n' | 'navigate'> & {
children: ReactNode;
};
export function Document({shop, i18n, navigate, children}: DocumentProps) {
return (
<html lang="en">
<head>
<ShopifyScripts
shop={shop}
i18n={i18n}
consent={analyticsConsent}
navigate={navigate}
routes={routeTemplates}
/>
</head>
<body>{children}</body>
</html>
);
}Vue
<script setup lang="ts">
import {ShopifyScripts} from '@shopify/hydrogen/vue';
import {RouterView, useRouter} from 'vue-router';
import {analyticsConsent, defaultI18n, shop} from './lib/config';
import {routeTemplates} from './lib/route-templates';
const router = useRouter();
async function navigate(url: string) {
await router.push(url);
}
</script>
<template>
<ShopifyScripts
:shop="shop"
:i18n="defaultI18n"
:consent="analyticsConsent"
:navigate="navigate"
:routes="routeTemplates"
/>
<RouterView />
</template>Pass your router's navigate function, like the one from React Router's useNavigate(), as navigate, and resolve shop and i18n in the server code for your root layout. To find where this code goes in your framework, refer to Framework wiring.
In Next.js, the root layout is a Server Component, so it can't call useRouter() or pass functions to client components. Render ShopifyScripts from a client component, call useRouter() there, pass (url) => router.push(url) as navigate, and render that component in the layout's <head>.
Vue's ShopifyScripts renders the tags where you place it. To put them in the document head with a head manager, like useHead(), pass the output of getShopifyScriptTags() to the head manager, and call initializeShopifyScripts() in onMounted(). In vanilla JavaScript, render the tags on the server with renderShopifyScriptTags(), or get them as data with getShopifyScriptTags(). Then call initializeShopifyScripts() from your client bundle, as the vanilla JavaScript example does.
Resolve the shop object from your server config:
Shop config
lib/config.tsThis example reads Next.js environment variable names. In React Router, use your app's names, like PUBLIC_STOREFRONT_ID.
The analytics.channel setting defaults to hydrogen, which attributes events to the storefront in shop.storefrontId. If your storefront uses the Headless channel, then pass analytics={{channel: 'headless'}}.
To omit Shopify's analytics destination script, pass shopifyAnalytics: false to ShopifyScripts or to the core helpers.
Anchor to Step 2: Publish page and resource eventsStep 2: Publish page and resource events
Read the bus through a small helper. The helper returns null on the server, so components can call it safely during rendering:
Analytics helper
lib/analytics.tsDon't read environment variables in this module, because it runs in the browser.
For every event key and the payload that each event takes, refer to AnalyticsEvent.
Anchor to Publish page viewsPublish page views
Publish PAGE_VIEWED on the first load and on every client navigation. Key the event by the pathname and the search string, so a navigation that changes only query parameters also counts:
Page view tracker
lib/page-views.ts
Vanilla JavaScript
import {AnalyticsEvent, getAnalytics} from './analytics';
let lastPageKey: string | null = null;
// Call on the first load and after every client navigation.
export function trackPageView() {
const pageKey = `${window.location.pathname}${window.location.search}`;
if (pageKey === lastPageKey) return;
lastPageKey = pageKey;
getAnalytics()?.publish(AnalyticsEvent.PAGE_VIEWED);
}React
import {useEffect} from 'react';
import {AnalyticsEvent, getAnalytics} from '../lib/analytics';
export function PageViewedTracker({pageKey}: {pageKey: string}) {
useEffect(() => {
getAnalytics()?.publish(AnalyticsEvent.PAGE_VIEWED);
}, [pageKey]);
return null;
}Vue
<script setup lang="ts">
import {onMounted, watch} from 'vue';
import {useRoute} from 'vue-router';
import {AnalyticsEvent, getAnalytics} from '../lib/analytics';
const route = useRoute();
onMounted(() => {
watch(
() => route.fullPath.split('#')[0],
() => getAnalytics()?.publish(AnalyticsEvent.PAGE_VIEWED),
{immediate: true},
);
});
</script>
<template>
<slot />
</template>Render the tracker once in your root layout. In React, pass a pageKey built from your router's path and search string, like ${location.pathname}${location.search} from React Router's useLocation(). In Vue, wrap your page content in the tracker. In vanilla JavaScript, call trackPageView() on startup, after each navigation in your router, and on popstate.
In Next.js, build pageKey from usePathname() and useSearchParams() in a client component, and wrap that component in <Suspense>. The useSearchParams() client-rendering bailout then applies only to the tracker, not to the whole layout.
Anchor to Publish resource viewsPublish resource views
Publish view events from the route that has the data, when that data resolves:
Resource view trackers
lib/view-events.ts
Vanilla JavaScript
import {AnalyticsEvent, getAnalytics} from './analytics';
export function publishProductViewed(product) {
const variant = product.selectedOrFirstAvailableVariant;
getAnalytics()?.publish(AnalyticsEvent.PRODUCT_VIEWED, {
products: [
{
id: product.id,
title: product.title,
price: variant?.price.amount ?? product.priceRange.minVariantPrice.amount,
vendor: product.vendor,
variantId: variant?.id ?? product.id,
variantTitle: variant?.title ?? product.title,
quantity: 1,
sku: variant?.sku ?? undefined,
},
],
});
}
export function publishCollectionViewed(collection) {
getAnalytics()?.publish(AnalyticsEvent.COLLECTION_VIEWED, {
collection: {id: collection.id, handle: collection.handle},
});
}
export function publishSearchViewed(searchTerm: string, totalCount: number) {
if (!searchTerm) return;
getAnalytics()?.publish(AnalyticsEvent.SEARCH_VIEWED, {
searchTerm,
searchResults: {totalCount},
});
}React
import {useEffect} from 'react';
import {AnalyticsEvent, getAnalytics} from '../lib/analytics';
export function ProductViewedTracker({product}) {
useEffect(() => {
const variant = product.selectedOrFirstAvailableVariant;
getAnalytics()?.publish(AnalyticsEvent.PRODUCT_VIEWED, {
products: [
{
id: product.id,
title: product.title,
price:
variant?.price.amount ?? product.priceRange.minVariantPrice.amount,
vendor: product.vendor,
variantId: variant?.id ?? product.id,
variantTitle: variant?.title ?? product.title,
quantity: 1,
sku: variant?.sku ?? undefined,
},
],
});
}, [product.handle]);
return null;
}
export function CollectionViewedTracker({collection}) {
useEffect(() => {
getAnalytics()?.publish(AnalyticsEvent.COLLECTION_VIEWED, {
collection: {id: collection.id, handle: collection.handle},
});
}, [collection.id, collection.handle]);
return null;
}
export function SearchViewedTracker({searchTerm, totalCount}) {
useEffect(() => {
if (!searchTerm) return;
getAnalytics()?.publish(AnalyticsEvent.SEARCH_VIEWED, {
searchTerm,
searchResults: {totalCount},
});
}, [searchTerm, totalCount]);
return null;
}Vue
<script setup lang="ts">
import {onMounted, watch} from 'vue';
import {publishProductViewed} from '../lib/view-events';
const props = defineProps(['product']);
onMounted(() => {
watch(
() => props.product.handle,
() => publishProductViewed(props.product),
{immediate: true},
);
});
</script>
<template>
<slot />
</template>In vanilla JavaScript, call each function after the page's data loads. In Vue, the product tracker calls the vanilla publishProductViewed() function when the product handle changes. Build collection and search trackers the same way, with publishCollectionViewed() and publishSearchViewed().
Follow these rules for resource events:
- Use the Shopify Product GID for
idand the ProductVariant GID forvariantId. Handles aren't analytics IDs. - Include
id,title,price,vendor,variantId, andvariantTitlefor every product. If a field is missing, then Shopify analytics skips the event and logs an error to the console. Your own destinations still receive it. - Publish
SEARCH_VIEWEDonly for non-empty search terms. - Publish
CART_VIEWEDwhen the customer opens the cart page or cart drawer. Pass the cart with itsid,updatedAt, andlines, or passcart: nullwhen no complete cart is available. Don't pass a partial cart.
View events read the page URL from window.location.href, so you don't need to pass it.
Anchor to Step 3: Track cart changesStep 3: Track cart changes
Hydrogen derives cart events, like product_added_to_cart, from confirmed changes in the cart store. In React and Vue, call useCartAnalytics() inside your CartProvider. In vanilla JavaScript, call trackCartAnalytics() with your cart store:
Cart analytics tracker
lib/cart-analytics.ts
Vanilla JavaScript
import {trackCartAnalytics} from '@shopify/hydrogen';
import {cartStore} from './cart';
// Call once in the browser, after ShopifyScripts creates the analytics bus.
export function startCartAnalytics(): () => void {
return trackCartAnalytics(cartStore);
}React
import {useCartAnalytics} from '@shopify/hydrogen/react';
export function CartAnalyticsTracker() {
useCartAnalytics();
return null;
}Vue
<script setup lang="ts">
import {useCartAnalytics} from '@shopify/hydrogen/vue';
useCartAnalytics();
</script>
<template>
<slot />
</template>In React, render the tracker once, as a child of CartProvider. In Vue, wrap your page content in the tracker, inside CartProvider.
The tracker skips pending updates and note changes, and publishes events only after Shopify confirms a cart change. Don't publish cart_updated, product_added_to_cart, or product_removed_from_cart yourself, because manual events duplicate or contradict the tracked cart history.
For the Storefront API cart query, select lines.nodes and each line's merchandise.price. The tracker reads both.
In vanilla JavaScript, call trackCartAnalytics(cartStore) from client-only startup code. It returns an unsubscribe function. Don't call it when you create the cart store, because it throws when window.Shopify.analytics doesn't exist, like during server rendering.
Anchor to Step 4: Add analytics destinationsStep 4: Add analytics destinations
To send events to another service, like Google Analytics or Meta Pixel, register a destination with addDestination(). The bus delivers events to destinations only when the visitor's consent allows analytics. When a destination registers, the bus replays up to 500 retained events that it subscribes to:
Console destination
lib/analytics.tsFollow these rules for destinations:
- Register each destination once per page lifetime, after
ShopifyScriptscreates the bus. Removing and adding a destination again replays retained events again, so registering on component mount can duplicate deliveries, including in React Strict Mode. - Subscribe to events inside the
setup()callback, with thesubscribefunction that it receives. - To read Shopify's visitor tokens, call
getTrackingValues()from the callback's second argument:subscribe(event, (payload, {getTrackingValues}) => {...}). It returns empty strings when analytics tracking isn't allowed. - To pass extra values to destinations, set
analytics.customDataonShopifyScripts. Destinations read it withgetConfig().customData. Hydrogen doesn't add it to the events that you publish. - Don't send events to Shopify's analytics endpoint yourself. Shopify's destination already does.
Anchor to Step 5: Handle consentStep 5: Handle consent
Shopify's Customer Privacy API decides for each visitor whether tracking needs consent, based on where they are. The bus blocks destination delivery until consent allows it. If the Customer Privacy script is blocked or hasn't loaded, then delivery stays blocked.
Choose a consent mode with the consent prop:
| Mode | Behavior |
|---|---|
default-banner | Loads Shopify's hosted privacy banner and shows it when the visitor's region requires it. |
custom-banner | Loads only the Customer Privacy API, and waits for your setup() callback to connect a third-party banner. |
no-banner | Loads only the Customer Privacy API. Use it only when you've already collected consent, or you manage consent outside the storefront. |
Use default-banner unless you have your own consent UI. With no-banner, visitors in regions that require consent can't grant it, so events never reach destinations.
The consent config accepts only the mode and, for custom banners, setup(). Don't pass country or language in the consent config. Pass the market in the i18n prop instead.
For a third-party consent provider, use custom-banner mode with an async setup() function. Hydrogen calls setup() in the browser after the Customer Privacy API loads, and holds all destination events until the returned promise resolves:
Custom consent banner
components/Document.tsxIn this example, provider, mapProviderConsent(), and reportConsentError() are adapters that you write for your consent provider. They aren't Hydrogen APIs.
Follow these rules for custom banners:
- Subscribe to provider changes before you read saved consent, so later changes stay in sync.
- Resolve the promise only after the provider has a choice and
setTrackingConsent()finishes. Showing the banner doesn't complete setup. - Map every choice to all four purposes:
analytics,marketing,preferences, andsale_of_data. Leave an unknown provider state pending, instead of submitting it as a denial. - In Next.js, define
setup()in the client component that rendersShopifyScripts. Functions can't pass from Server Components to client components.
Hydrogen runs setup() once per page. If it fails, then Hydrogen logs the error and keeps delivery blocked. Retry inside setup() while its promise is still pending, if your provider needs it.
The consent gate covers Hydrogen analytics destinations. Scripts that you load separately need their own consent handling.
Anchor to Step 6: Verify analyticsStep 6: Verify analytics
To test your setup, add the console destination and open your storefront with the browser's developer tools open:
- Check the console for
[analytics] page_viewedafter consent allows tracking. If nothing logs, then check thatShopifyScriptsrenders and that the visitor has granted consent. - In the Network tab, filter for
monorail-edge.shopifysvc.com. Aproduce_batchrequest confirms that events reach Shopify. - Navigate between pages. Each navigation logs a new
page_viewedevent. - Add an item to the cart. A new
produce_batchrequest confirms that theproduct_added_to_cartevent reached Shopify. - With
default-bannermode, simulate a visitor in the EU, for example with a location override in your browser's developer tools. The privacy banner appears.
If your Content Security Policy blocks cdn.shopify.com, then the Customer Privacy script can't load and events never reach destinations. Add cdn.shopify.com to script-src.
Shopify skips analytics requests from Chrome Lighthouse, so synthetic monitoring that uses Lighthouse doesn't show produce_batch requests.