Skip to main content

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.


Hydrogen's analytics has three parts:

  • ShopifyScripts loads Shopify's Customer Privacy API, the privacy banner, and Shopify's analytics destination. It also creates one analytics bus for the page at window.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.


  • 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_ID environment variable to your numeric shop ID. To attribute traffic to a Hydrogen storefront, also set PUBLIC_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

Render 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:

PropDescription
shopYour shop's shopId, storefrontId, and permanent myshopifyDomain. Use "0" for storefrontId if you don't have one.
i18nThe active market's country and language, with an optional currency.
consentHow Hydrogen collects consent. Refer to Step 5.
navigateYour router's navigate function, so Shopify scripts can navigate without a full page reload.
routesYour route templates, so Shopify scripts can build storefront URLs.
analyticsOptional bus settings, like channel or customData.

Render ShopifyScripts

lib/shopify-scripts.ts

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,
});
}
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>
);
}
<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.ts
import type {ShopifyScriptsShop} from '@shopify/hydrogen';

export const shop: ShopifyScriptsShop = {
shopId: process.env.NEXT_PUBLIC_SHOP_ID!,
storefrontId: process.env.NEXT_PUBLIC_STOREFRONT_ID || '0',
myshopifyDomain: process.env.NEXT_PUBLIC_STORE_DOMAIN!,
};

export const defaultI18n = {country: 'US', language: 'EN'} as const;

export const analyticsConsent = {mode: 'default-banner'} as const;

This 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.ts
import {AnalyticsEvent, type StorefrontAnalytics} from '@shopify/hydrogen';

export {AnalyticsEvent};

let bus: StorefrontAnalytics | null = null;

export function getAnalytics(): StorefrontAnalytics | null {
if (typeof window === 'undefined') return null;
bus ??= window.Shopify?.analytics ?? null;
return bus;
}

Don'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.

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

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);
}
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;
}
<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

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},
});
}
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;
}
<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 id and the ProductVariant GID for variantId. Handles aren't analytics IDs.
  • Include id, title, price, vendor, variantId, and variantTitle for 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_VIEWED only for non-empty search terms.
  • Publish CART_VIEWED when the customer opens the cart page or cart drawer. Pass the cart with its id, updatedAt, and lines, or pass cart: null when 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

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);
}
import {useCartAnalytics} from '@shopify/hydrogen/react';

export function CartAnalyticsTracker() {
useCartAnalytics();
return null;
}
<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.ts
export function addAnalyticsConsoleDestination(): (() => void) | null {
const analytics = getAnalytics();
if (!analytics) return null;

return analytics.addDestination({
name: 'example-console-logger',
setup({subscribe}) {
const events = [
AnalyticsEvent.PAGE_VIEWED,
AnalyticsEvent.PRODUCT_VIEWED,
AnalyticsEvent.COLLECTION_VIEWED,
AnalyticsEvent.CART_VIEWED,
AnalyticsEvent.SEARCH_VIEWED,
] as const;
const unsubscribers = events.map((event) =>
subscribe(event, (payload) => {
console.log(`[analytics] ${event}`, payload);
}),
);

return () => {
for (const unsubscribe of unsubscribers) unsubscribe();
};
},
});
}

Follow these rules for destinations:

  • Register each destination once per page lifetime, after ShopifyScripts creates 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 the subscribe function 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.customData on ShopifyScripts. Destinations read it with getConfig().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.

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:

ModeBehavior
default-bannerLoads Shopify's hosted privacy banner and shows it when the visitor's region requires it.
custom-bannerLoads only the Customer Privacy API, and waits for your setup() callback to connect a third-party banner.
no-bannerLoads 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.

Anchor to Connect a custom bannerConnect a custom banner

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.tsx
<ShopifyScripts
shop={shop}
i18n={i18n}
consent={{
mode: 'custom-banner',
async setup() {
const customerPrivacy = window.Shopify!.customerPrivacy!;
const sync = async (choice) => {
await customerPrivacy.setTrackingConsent(mapProviderConsent(choice));
};
const unsubscribe = provider.subscribe((choice) => {
void sync(choice).catch(reportConsentError);
});
try {
await provider.whenConsentResolved();
await sync(provider.getResolvedConsent());
} catch (error) {
unsubscribe();
throw error;
}
},
}}
/>

In 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, and sale_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 renders ShopifyScripts. 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:

  1. Check the console for [analytics] page_viewed after consent allows tracking. If nothing logs, then check that ShopifyScripts renders and that the visitor has granted consent.
  2. In the Network tab, filter for monorail-edge.shopifysvc.com. A produce_batch request confirms that events reach Shopify.
  3. Navigate between pages. Each navigation logs a new page_viewed event.
  4. Add an item to the cart. A new produce_batch request confirms that the product_added_to_cart event reached Shopify.
  5. With default-banner mode, 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.



Was this page helpful?