Integrating Shopify Analytics Into Your Next.js Custom Storefront
How to recreate Shopify's analytics tracking — the _shopify_y and _shopify_s cookies and its event helpers — inside a custom Next.js headless storefront, so your headless traffic reports just like a native theme.
Move a Shopify store onto a custom Next.js storefront and you gain everything headless promises — full control of the front end, App Router rendering, your own design system. But you quietly lose something the native theme gave you for free: analytics. Shopify's reporting, marketing attribution and Shop-channel data all depend on a tracking layer that simply isn't there once you stop rendering Liquid.
The fix is to reproduce that tracking layer yourself. In practice that means handling Shopify's analytics cookies — _shopify_y and _shopify_s — and emitting the same page-view and add-to-cart events Shopify's own stack would, from inside your Next.js app. This is a hands-on walkthrough of exactly how we wire that up on a Next.js 16 App Router storefront.
Unravelling the complexity
Shopify's storefront analytics revolve around two cookies. _shopify_y is a long-lived visitor identifier, and _shopify_s is a short-lived session token. Every analytics event Shopify records is keyed against that pair. On a native theme they're set and read automatically; on a headless storefront, nothing sets them and nothing sends events — so as far as Shopify is concerned, your traffic doesn't exist.
There are three problems to solve, and they're easy to conflate. First, the cookies have to actually exist in the browser. Second, your app has to read them and attach them to every analytics call. Third, the cart mutations you send to the Storefront API have to carry the same identity, or your add-to-cart events won't reconcile with the orders Shopify eventually sees. Get one of the three wrong and the data looks plausible but is silently incomplete.
If you're still weighing the architecture itself rather than the analytics layer, our guide to choosing the right tool for headless e-commerce covers the trade-offs. Here we assume the decision is made and Next.js is already in front of Shopify.
Implementing the solution
The cleanest approach leans on Shopify's own Hydrogen React utilities, which already know the shape of every analytics payload. We'll install them, wrap them in a small hook, fire page views from the layout, hook add-to-cart into a server action, and finally solve the local-development cookie problem with a tunnel.
Installing dependencies
The heavy lifting lives in @shopify/hydrogen-react — the framework-agnostic half of Hydrogen. It exposes sendShopifyAnalytics, the event-name constants and the browser-parameter helper, all of which work fine inside a plain Next.js app talking to Shopify.
pnpm add @shopify/hydrogen-reactYou'll also need a few public environment variables — your numeric shop ID, the store's currency and the accepted language. These are safe to expose because they're already visible in any storefront request:
NEXT_PUBLIC_SHOPIFY_SHOP_ID=00000000000
NEXT_PUBLIC_SHOPIFY_CURRENCY=AED
NEXT_PUBLIC_SHOPIFY_LANGUAGE=ENThe useShopifyAnalytics hook
Everything funnels through one hook. It takes the two cookie values, assembles the base payload that every Shopify event shares — shop ID, currency, language, consent flag and the visitor/session tokens — and returns typed sendPageView and sendAddToCart helpers. Centralising it here means the rest of the app never touches a raw analytics payload.
"use client";
import { useCallback } from "react";
import {
AnalyticsEventName,
getClientBrowserParameters,
sendShopifyAnalytics,
type ShopifyPageViewPayload,
type ShopifyAddToCartPayload,
} from "@shopify/hydrogen-react";
// These come straight from your Shopify admin / storefront config.
const SHOP_ID = `gid://shopify/Shop/${process.env.NEXT_PUBLIC_SHOPIFY_SHOP_ID}`;
const CURRENCY = process.env.NEXT_PUBLIC_SHOPIFY_CURRENCY ?? "AED";
const LANGUAGE = process.env.NEXT_PUBLIC_SHOPIFY_LANGUAGE ?? "EN";
type ShopifyCookies = { unique: string; session: string };
/**
* Reads the Shopify analytics cookies (_shopify_y / _shopify_s) that the
* storefront sets, and exposes helpers that emit the same events Shopify's
* native stack would. Without these, Shopify has no idea your headless
* traffic exists.
*/
export function useShopifyAnalytics(cookies: ShopifyCookies) {
const base = {
shopId: SHOP_ID,
currency: CURRENCY,
acceptedLanguage: LANGUAGE,
hasUserConsent: true,
// _shopify_y is the long-lived visitor id, _shopify_s the session id.
uniqueToken: cookies.unique,
visitToken: cookies.session,
...getClientBrowserParameters(),
};
const sendPageView = useCallback(
(pagePayload: Partial<ShopifyPageViewPayload> = {}) => {
sendShopifyAnalytics({
eventName: AnalyticsEventName.PAGE_VIEW,
payload: { ...base, ...pagePayload },
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[cookies.unique, cookies.session],
);
const sendAddToCart = useCallback(
(cartPayload: Omit<ShopifyAddToCartPayload, keyof typeof base>) => {
sendShopifyAnalytics({
eventName: AnalyticsEventName.ADD_TO_CART,
payload: { ...base, ...cartPayload },
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[cookies.unique, cookies.session],
);
return { sendPageView, sendAddToCart };
}The two tokens map directly onto the cookies: uniqueToken is _shopify_y and visitToken is _shopify_s. getClientBrowserParameters() fills in everything Shopify wants about the current browser — viewport, referrer, user agent — so you don't assemble it by hand.
The ShopifyAnalytics component
Page views are the easy win. A tiny client component mounted once in the root layout watches the App Router pathname and fires a PAGE_VIEW on every navigation — including the soft client-side transitions that a server-only approach would miss entirely.
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useShopifyAnalytics } from "@/lib/use-shopify-analytics";
/**
* Mount once in the root layout. It fires a Shopify PAGE_VIEW on every
* client navigation, reading the analytics cookies the storefront set.
*/
export function ShopifyAnalytics({
cookies,
}: {
cookies: { unique: string; session: string };
}) {
const pathname = usePathname();
const searchParams = useSearchParams();
const { sendPageView } = useShopifyAnalytics(cookies);
useEffect(() => {
sendPageView({
url: window.location.href,
path: pathname,
});
}, [pathname, searchParams, sendPageView]);
return null;
}Read the cookies on the server, in the layout, and pass them down as props. In the App Router that means a await cookies() call in app/layout.tsx, then <ShopifyAnalytics cookies={{ unique, session }} /> rendered alongside your other providers. The component itself stays a thin client boundary.
Wiring add-to-cart tracking
Add-to-cart is where most headless integrations leak data, because the event and the actual cart mutation happen in two different places. The mutation runs in a server action; the analytics event has to run in the browser. The trick is to let the server action do the cart work, return just enough about the added line, and let the client fire the matching event once it succeeds.
First, the server action. It reads the cookies on the server, forwards them into the cart mutation, and hands the resulting line back to the client:
"use server";
import { cookies } from "next/headers";
import { revalidateTag } from "next/cache";
import { addToCart, getCart } from "@/lib/shopify";
/**
* Server action invoked by the AddToCart component. It forwards the Shopify
* analytics cookies to the Storefront API so the cart mutation is attributed
* to the right visitor, then returns the line so the client can fire the
* matching analytics event.
*/
export async function addItem(prevState: unknown, variantId: string) {
if (!variantId) return "Missing product variant ID";
const cookieStore = await cookies();
const cartId = cookieStore.get("cartId")?.value;
const shopify = {
unique: cookieStore.get("_shopify_y")?.value ?? "",
session: cookieStore.get("_shopify_s")?.value ?? "",
};
try {
const cart = await addToCart(cartId, [{ merchandiseId: variantId, quantity: 1 }], shopify);
revalidateTag("cart");
const line = cart.lines.find((l) => l.merchandise.id === variantId);
// Hand the client the data it needs to emit ADD_TO_CART.
return {
ok: true as const,
cartId: cart.id,
product: line && {
productGid: line.merchandise.product.id,
variantGid: line.merchandise.id,
name: line.merchandise.product.title,
price: line.merchandise.price.amount,
quantity: 1,
},
};
} catch {
return "Error adding item to cart";
}
}Then the AddToCart component consumes the action's result and emits the ADD_TO_CART event with the same cookies, so the browser-side event and the server-side mutation describe one coherent visitor:
"use client";
import { useActionState, useEffect } from "react";
import { useShopifyAnalytics } from "@/lib/use-shopify-analytics";
import { addItem } from "@/lib/actions";
import type { ProductVariant } from "@/lib/shopify/types";
export function AddToCart({
variant,
cookies,
}: {
variant: ProductVariant;
cookies: { unique: string; session: string };
}) {
const { sendAddToCart } = useShopifyAnalytics(cookies);
const [state, formAction, pending] = useActionState(addItem, null);
// When the action succeeds, mirror the event to Shopify analytics.
useEffect(() => {
if (state && typeof state === "object" && state.ok && state.product) {
sendAddToCart({
cartId: state.cartId,
products: [
{
productGid: state.product.productGid,
variantGid: state.product.variantGid,
name: state.product.name,
price: state.product.price,
quantity: state.product.quantity,
},
],
totalValue: Number(state.product.price),
});
}
}, [state, sendAddToCart]);
return (
<form action={() => formAction(variant.id)}>
<button type="submit" disabled={pending || !variant.availableForSale}>
{pending ? "Adding…" : "Add to cart"}
</button>
</form>
);
}The subtle part is the mutation itself. The Storefront API call has to carry the analytics cookies in its request headers — otherwise Shopify attributes the cart to an anonymous session and your add-to-cart events never reconcile with the eventual order. Pass them straight through as a Cookie header:
import { TAGS } from "@/lib/constants";
const endpoint = `https://${process.env.SHOPIFY_STORE_DOMAIN}/api/2025-01/graphql.json`;
type ShopifyCookies = { unique: string; session: string };
async function shopifyFetch<T>({
query,
variables,
cookies,
tags,
}: {
query: string;
variables?: Record<string, unknown>;
cookies?: ShopifyCookies;
tags?: string[];
}): Promise<T> {
const result = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Storefront-Access-Token": process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!,
// Forward the analytics cookies so the mutation is attributed to the
// same visitor Shopify is tracking. Skipping this breaks attribution.
...(cookies && {
Cookie: `_shopify_y=${cookies.unique}; _shopify_s=${cookies.session}`,
}),
},
body: JSON.stringify({ query, variables }),
next: { tags },
});
return result.json() as Promise<T>;
}
export async function addToCart(
cartId: string | undefined,
lines: { merchandiseId: string; quantity: number }[],
cookies: ShopifyCookies,
) {
const res = await shopifyFetch<{ data: { cartLinesAdd: { cart: Cart } } }>({
query: addToCartMutation,
variables: { cartId, lines },
cookies,
tags: [TAGS.cart],
});
return res.data.cartLinesAdd.cart;
}Configuring ngrok for local cookies
Here's the gotcha that costs people an afternoon: none of this works on localhost. Shopify only issues _shopify_y and _shopify_s for a real, resolvable domain, so in local development the cookies are simply never set and every event ships with empty tokens. The fix is a tunnel — point a stable public domain at your dev server with ngrok and develop against that URL instead of localhost:
# Shopify sets _shopify_y / _shopify_s only on a real, resolvable host —
# they never appear on http://localhost. Tunnel a stable public domain to
# your dev server so the cookies are issued in local development.
ngrok http 3000 --domain=your-store.ngrok.appUse a reserved custom domain rather than a random ngrok subdomain. The visitor cookie is scoped to the host, so a domain that changes on every restart throws away your test session each time. Add the tunnel domain to your Shopify storefront's allowed origins and set it as the app URL while you develop.
Additional notes and gotchas
Consent is not optional. The hasUserConsent flag in the payload must reflect the visitor's real choice. If you run a consent banner, gate the analytics calls behind it rather than hard-coding true in production — both for compliance and because Shopify's Customer Privacy API expects it.
Don't double-count page views. Because the App Router fires navigation effects on both the initial load and subsequent transitions, make sure your effect's dependency array is keyed on the pathname (and search params if they matter), so each distinct view emits exactly one event.
Server actions need the cookies, not just the client. It's tempting to fire analytics only from the browser. But the cart mutation is the source of truth for attribution, so the cookies have to ride along on the Storefront API request too. If only the client event carries them, your funnel will show add-to-carts that never tie back to sessions.
Validate in Shopify, not the console. A successful sendShopifyAnalytics call logs nothing useful. Confirm the integration by watching real-time visitors and the Shop channel reports in your Shopify admin, and by inspecting the network request to Shopify's monorail endpoint in dev tools.
Done properly, a headless Next.js storefront reports into Shopify exactly as a native theme would — page views, add-to-carts and the visitor identity that ties them together — while you keep every advantage of owning the front end. The cookies and a thin event layer are all that stand between a custom storefront and analytics parity.
Why do Shopify analytics cookies fail on a headless Next.js storefront?
Shopify's native analytics depends on first-party cookies (_shopify_y, _shopify_s) and the storefront scripts Shopify injects on its own hosted theme and checkout. A headless Next.js storefront runs on your own domain, so those cookies are never set and the pixel never loads — session and attribution data break. You restore tracking by emitting your own page-view and add-to-cart events with @shopify/hydrogen-react and forwarding the Shopify cookies on cart mutations.
Do I need @shopify/hydrogen-react to send analytics, or can I use plain fetch?
You can hand-roll the calls, but @shopify/hydrogen-react already exposes sendShopifyAnalytics plus the payload helpers that match Shopify's expected event schema, so you avoid reverse-engineering event names and shapes. For a custom Next.js storefront it's the lowest-friction path — you only need the analytics utilities, not the full Hydrogen framework.
How do I test the integration locally, and why is ngrok needed?
Shopify's analytics cookies are scoped to a real domain and won't set on localhost, so events fire with empty session ids in development. Tunnelling localhost through ngrok — ideally with a stable custom domain — gives you a public HTTPS origin where the cookies behave like production, letting you verify events end-to-end before you deploy.
Will this double-count sessions against Shopify's native reports?
No. Because the native storefront pixel doesn't run on your headless domain, you're replacing that signal rather than duplicating it. As long as each event fires once — one page view per navigation, one add-to-cart per action — Shopify's reports stay accurate. Watch for React re-renders firing duplicate events, and audit the Shopify analytics dashboard after launch to confirm.
Is the integration compatible with the App Router and server actions?
Yes. Page views are sent from a small client component mounted in the layout, while cart mutations run inside server actions that read the Shopify cookies from the request and forward them in the Storefront API headers. That split keeps tracking working across App Router navigations without shipping unnecessary client JavaScript.