Skip to main content

Data and tags

Shopify

The c15t Shopify bridge sends each consent choice to Shopify's Customer Privacy API. Shopify's analytics, and the apps and pixels that read that API, then follow your c15t banner instead of Shopify's own. The bridge loads no script. Hydrogen's ShopifyScripts loads the Customer Privacy API, and the bridge's setup function holds Hydrogen's analytics events until Shopify has the visitor's choice.

The Shopify example runs the code on this page in a Next.js app against mock.shop.

Add the bridge

Set up c15t with the Next.js quickstart first, then install the integrations package:

npm install @c15t/integrations

Create the bridge once per page and pass its setup to ShopifyScripts with mode: 'custom-banner'. A second component feeds the bridge each c15t snapshot:

components/shopify-analytics.tsx
'use client';

import { createShopifyCustomerPrivacy } from '@c15t/integrations/shopify';
import type { ShopifyScriptsI18n, ShopifyScriptsShop } from '@shopify/hydrogen';
import { ShopifyScripts } from '@shopify/hydrogen/react';
import { useSnapshot } from 'c15t/next';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';

// One bridge per page. It sends each c15t choice to Shopify's Customer
// Privacy API, and its `setup` holds Hydrogen's analytics until Shopify has
// the visitor's choice.
const shopifyPrivacy = createShopifyCustomerPrivacy();

/** Hydrogen's scripts and analytics bus. Render it in the document head. */
export const ShopifyAnalytics = ({
	i18n,
	shop,
}: {
	i18n: ShopifyScriptsI18n;
	shop: ShopifyScriptsShop;
}) => {
	const router = useRouter();
	return (
		<ShopifyScripts
			analytics={{ channel: 'headless' }}
			consent={{ mode: 'custom-banner', setup: shopifyPrivacy.setup }}
			i18n={i18n}
			// Shopify's scripts route storefront links through Next's router.
			navigate={(url) => router.push(url)}
			shop={shop}
		/>
	);
};

/** Feeds each c15t snapshot to the bridge. Render it inside `ConsentRoot`. */
export const ShopifyConsentSync = () => {
	const snapshot = useSnapshot();
	useEffect(() => shopifyPrivacy.update(snapshot), [snapshot]);
	return null;
};

Render ShopifyAnalytics in the document head, as Hydrogen requires, with your shop's details. Render ShopifyConsentSync inside ConsentRoot:

app/layout.tsx
import {
	ConsentBanner,
	ConsentDialog,
	ConsentDialogLink,
	ConsentRoot,
} from 'c15t/next';
import { resolveConsent } from 'c15t/next/server';
import { connection } from 'next/server';
import { Suspense } from 'react';
import type { ReactNode } from 'react';

import { PageViewTracker } from '@/components/page-view-tracker';
import {
	ShopifyAnalytics,
	ShopifyConsentSync,
} from '@/components/shopify-analytics';
import { SiteHeader } from '@/components/site-header';
import { StoreProviders } from '@/components/store-providers';
import { currency, i18n, shop } from '@/lib/shop';
import { getCart } from '@/lib/storefront';

import './globals.css';

const RootLayout = async ({ children }: { children: ReactNode }) => {
	// The cart comes from the visitor's cookie, so every page renders per request.
	await connection();

	return (
		<html lang="en">
			<head>
				{/* Hydrogen's scripts belong in the head, once per page. */}
				<ShopifyAnalytics i18n={{ ...i18n, currency }} shop={shop} />
			</head>
			<body>
				<ConsentRoot state={resolveConsent()}>
					<ShopifyConsentSync />
					<StoreProviders cart={getCart()}>
						<SiteHeader />
						<Suspense fallback={null}>
							<PageViewTracker />
						</Suspense>
						{children}
					</StoreProviders>
					<ConsentBanner />
					<ConsentDialog />
					<footer className="site-footer">
						<ConsentDialogLink>Privacy settings</ConsentDialogLink>
					</footer>
				</ConsentRoot>
			</body>
		</html>
	);
};

export default RootLayout;

Declare the categories

c15t's dialog offers only the categories that something on the page uses. Shopify's scripts load through ShopifyScripts, not through c15t's scripts option, so c15t cannot find them. Declare the categories the bridge maps, or the dialog shows only Necessary and Shopify receives a denial for everything:

c15t.config.ts
import { defineConsentConfig } from 'c15t/next';

export default defineConsentConfig({
	options: {
		// The categories the Shopify bridge maps. Shopify's scripts load through
		// Hydrogen's `ShopifyScripts`, not `scripts`, so c15t can't discover them.
		consentCategories: ['measurement', 'marketing', 'functionality'],
	},
});

The categories must also be in your policy's scope.

Serve the Storefront API route

The Customer Privacy API saves each choice by posting to the storefront's own /api/<version>/graphql.json. Hydrogen's handleShopifyRoutes serves that route, forwards it to your shop and keeps the cookies Shopify sets. In Next.js, call it from proxy.ts:

proxy.ts
import {
	createShopifyRequestContext,
	createStorefrontClient,
	handleShopifyRoutes,
} from '@shopify/hydrogen';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

import { cartHandlers } from '@/lib/cart-handlers';
import { i18n, storeDomain } from '@/lib/shop';

// This store has no customer accounts, so nothing keeps a session.
const noSession = {
	getSessionItem: () => undefined,
	getSessionOrigin: () => '',
	removeSessionItem: () => undefined,
	setSessionItem: () => undefined,
};

/**
 * Serves Hydrogen's routes: `/api/cart` for the cart, and the Storefront API
 * at `/api/<version>/graphql.json`, where Shopify's Customer Privacy API
 * saves each consent choice.
 */
export const proxy = (request: NextRequest) => {
	const requestContext = createShopifyRequestContext({ i18n, request });
	const storefrontClient = createStorefrontClient({
		config: { publicStorefrontToken: undefined, storeDomain },
		requestContext,
		type: 'public',
	});

	const shopifyRoute = handleShopifyRoutes({
		handlers: [cartHandlers],
		request,
		requestContext,
		sessionManager: noSession,
		storefrontClient,
	});
	if (shopifyRoute) {
		return shopifyRoute;
	}

	const response = NextResponse.next({
		request: { headers: requestContext.getForwardedRequestHeaders() },
	});
	requestContext.applyResponseHeaders(response.headers);
	return response;
};

export const config = {
	matcher: ['/((?!_next/static|_next/image|_next/data|favicon.ico).*)'],
};

The consent route needs no handlers. The example adds Hydrogen's cart handlers for its cart, and its lib/shop.ts holds the store domain and market. It has no customer accounts, so it passes a session manager that stores nothing. Pass your own if your storefront signs customers in.

How categories map

Shopify consentc15t category
analyticsmeasurement
marketingmarketing
preferencesfunctionality
sale_of_datamarketing

Each Shopify value is true only while its c15t category is allowed in the effective permissions, which include policy restrictions. Pass categories to change the mapping:

const shopifyPrivacy = createShopifyCustomerPrivacy({
	categories: { sale_of_data: 'measurement' },
});

toShopifyConsent(permissions, categories) returns the same four values for your own code.

When the bridge sends

Shopify records consent only when the visitor makes a choice, so the bridge sends nothing until then, or while the policy is pending.

  • Opt-in or IAB policy: setup waits until the visitor answers the banner, including with Reject All, so Hydrogen holds events until then.
  • Opt-out policy: setup resolves without recording a choice, and Shopify applies its region defaults, including Global Privacy Control, until the visitor saves a choice in c15t. A stale grant is the exception, below.

After the first choice, the bridge sends each change once. When a visitor revokes a category, c15t reloads the page and the bridge sends the stored choice on load.

While the visitor has no current choice, Shopify may still hold grants from an earlier one, for example after that choice expired or a policy change asks again. If c15t now denies any of them, the bridge sends false for those grants only, so Shopify keeps the other signals, including unset ones. It counts grants the bridge sent and each yes from Shopify's currentVisitorConsent(), and sends the revocation again if Shopify reports a dropped grant again, for example after another tab granted it. If that function isn't available, it assumes a choice c15t stored may have granted anything. Under an opt-in or IAB policy, Hydrogen keeps holding events until the visitor answers. Under opt-out, setup resolves once Shopify accepts the revocation.

If the Customer Privacy API hasn't loaded yet, the bridge waits for Shopify's consentTrackingApiLoaded or shopifyCustomerPrivacyApiLoaded event.

setup resolves once Shopify has accepted the visitor's latest choice, and Hydrogen then delivers the events that choice allows. If the visitor changes their mind while the first request is in flight, setup waits for the newer one. It never rejects, because Hydrogen stops calling setup after a rejection. When Shopify rejects a choice, the bridge keeps events blocked and sends it again after 1, 2 and 4 seconds, then on the next update. If the policy turns pending meanwhile, the bridge drops those retries.

Options

OptionDefaultBehavior
categoriesThe table aboveOverrides the c15t category for any of the four Shopify values.
onSyncNoneCalled with what Shopify accepted: the four values for a choice, or the dropped grants for a revocation.
onErrorLogs to the consoleCalled when Shopify rejects a choice or onSync throws.

The bridge returns update(snapshot), connect(source) and setup(). connect follows anything with getSnapshot and subscribe, such as a c15t kernel, and returns a function that stops following it.

Limits

  • Checkout runs on Shopify's domain and doesn't render your storefront code. Shopify reads the stored choice there.
  • Shopify's analytics need a real shop. mock.shop accepts the consent request but sets no consent cookie, so currentVisitorConsent() stays empty.
  • The bridge maps categories only; it doesn't translate IAB TCF choices.

Verify the bridge

  1. In a private window with an opt-in policy, load the page. The network panel shows no graphql.json request with visitorConsent.
  2. Click Reject All. One request goes out with every value false.
  3. Open the dialog, allow Marketing and save. One request goes out with marketing and saleOfData set to true.
  4. On a real shop, run window.Shopify.customerPrivacy.currentVisitorConsent() in the console and check it matches your choice.