---
title: Shopify
description: Send c15t consent to Shopify's Customer Privacy API from a Hydrogen
  storefront, so Shopify analytics wait for the visitor's choice and follow each
  later change.
seoTitle: Shopify Hydrogen cookie consent with c15t
seoDescription: Use your c15t banner as a custom consent banner in Shopify
  Hydrogen. The c15t Shopify bridge passes each choice to Shopify's Customer
  Privacy API.
group: integrations
lastModified: "2026-10-17T17:24:06-07:00"
lastAuthor: Kaylee
---
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](https://github.com/c15t/c15t/tree/v3/examples/shopify-nextjs)
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](/docs/frameworks/next/quickstart)
first, then install the integrations package:

|Package manager|Command|
|:--|:--|
|npm|`npm install @c15t/integrations`|
|pnpm|`pnpm add @c15t/integrations`|
|yarn|`yarn add @c15t/integrations`|
|bun|`bun add @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:

```tsx title="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`:

```tsx title="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:

```ts title="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`:

```ts title="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 consent|c15t category|
|--|--|
|`analytics`|`measurement`|
|`marketing`|`marketing`|
|`preferences`|`functionality`|
|`sale_of_data`|`marketing`|

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:

```ts
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

|Option|Default|Behavior|
|--|--|--|
|`categories`|The table above|Overrides the c15t category for any of the four Shopify values.|
|`onSync`|None|Called with what Shopify accepted: the four values for a choice, or the dropped grants for a revocation.|
|`onError`|Logs to the console|Called 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.
