Consent Management for A/B Testing
From @pack/hydrogen 4.0, Pack records each visitor's consent and honors it.
Stores using Shopify's privacy banner get this without writing any code. Stores
using another consent tool connect it with a few lines. Storefronts on earlier
versions manage consent themselves; see
Before @pack/hydrogen 4.0.
What consent controls
Pack records an answer for four purposes: analytics, marketing, preferences and sale of data. Each is granted, denied, or not yet answered. Today Pack acts on analytics:
- The Pack visitor id. With analytics consent, Pack keeps one id for the
visitor across visits. Without it, Pack uses a new id on every request. This
is the id sent with Pack's analytics and each CMS request, and returned by
useAbTestSessionId(). - What it does not cover. Pack's analytics still records page views without consent. Each carries the visitor's IP address and an id derived from their browser, IP address and your hostname that changes daily, so page views from the same browser can be linked within a day.
- Exposure events.
PackTestProvideronly reports test exposures, and only records them in the__pack_exposedcookie, with analytics consent. If you do not passhasUserConsent, it follows Pack's answer, which it reads from your root loader's...pack.getPackContextData(). Without that in the root loader, no exposures are reported. - The Cookie audience condition. With require consent on, a visitor
without analytics consent does not match. That includes a visitor who has not
answered when
unknownConsentis'denied'(below).
Showing tests and personalizations does not depend on consent. The cookies that
do this (__pack_test and __pack_s) are set and read by your own storefront
and are not sent to Pack. The tests and variants a visitor is in are sent with
each CMS request, so Pack returns the right content.
Visitors who have not answered
By default Pack treats a visitor who has not answered a banner as consenting, which is how storefronts behaved before 4.0. Every visitor's first page view is unanswered, as is anyone who ignores the banner.
To change that, pass unknownConsent to PackSession.init in server.ts:
server.ts
PackSession.init(request, [env.SESSION_SECRET], {
unknownConsent: 'denied',
}),
It also takes a function, for example to decide by the visitor's country. It
receives the request and returns 'granted' or 'denied'. If it throws, Pack
treats the visitor as not consenting.
Shopify's privacy banner
If your storefront uses Shopify's privacy banner and wraps the app in
PackProvider from @pack/react, there is nothing to add.
PackProvider reads the visitor's answer from Shopify's Customer Privacy API
when the page loads and whenever they answer the banner, and sends it to Pack.
Pack stores it in the __pack_user_consent cookie, set by your storefront's
server so that Safari does not expire it after 7 days. Only purposes the visitor
has answered, and that changed, are sent.
Shopify's consent banner may only appear in production. See Preview vs. production.
Other consent tools
For OneTrust, Cookiebot and similar tools, call setPackConsent() from
@pack/react with the visitor's answer, both when the page loads and whenever
they change it. For OneTrust:
app/root.tsx or a client component
import {useEffect} from 'react';
import {setPackConsent} from '@pack/react';
declare global {
interface Window {
OneTrust?: {IsAlertBoxClosed?: () => boolean};
OnetrustActiveGroups?: string;
}
}
useEffect(() => {
const sync = () => {
// Before the visitor answers, OneTrust's groups are only defaults.
if (!window.OneTrust?.IsAlertBoxClosed?.()) return;
const groups = window.OnetrustActiveGroups || ''; // e.g. ",C0001,C0002,"
setPackConsent({
analytics: groups.includes(',C0002,'),
preferences: groups.includes(',C0003,'),
marketing: groups.includes(',C0004,'),
});
};
sync(); // OneTrust may have loaded before this ran
// Fires when OneTrust loads and whenever the visitor changes their answer.
window.addEventListener('OneTrustGroupsUpdated', sync);
return () => window.removeEventListener('OneTrustGroupsUpdated', sync);
}, []);
- Sending the answer on every page load, not only when it changes, means an
answer given before your storefront upgraded to
@pack/hydrogen4.0 still reaches Pack. - Answers that already match what Pack has recorded are not sent again, so the load-time call costs nothing once Pack has the answer.
- Purposes you leave out keep their current answer.
- OneTrust group ids are set per OneTrust account.
C0002,C0003andC0004are OneTrust's defaults; map the groups your account uses.
Optional: read the tool's cookie on the server
setPackConsent() is enough on its own. To also apply the tool's answer on the
very first request after the visitor answers, pass getConsent to
PackSession.init. When it returns an answer for a purpose, that answer wins
over Pack's cookie.
server.ts
PackSession.init(request, [env.SESSION_SECRET], {
getConsent: (req) => {
const raw = /(?:^|;\s*)OptanonConsent=([^;]*)/.exec(
req.headers.get('Cookie') || '',
)?.[1];
if (!raw) return undefined;
const params = new URLSearchParams(raw);
// OneTrust writes default groups before the visitor answers.
if (Number(params.get('interactionCount') || 0) < 1) return undefined;
const groups = Object.fromEntries(
(params.get('groups') || '').split(',').map((group) => group.split(':')),
);
const on = (id: string) =>
groups[id] === undefined ? undefined : groups[id] === '1';
return {
analytics: on('C0002'),
preferences: on('C0003'),
marketing: on('C0004'),
};
},
}),
getConsent runs on every request before the page renders, with no timeout.
Read the tool's cookie from the request, as above, rather than calling an API.
Return undefined for a purpose the tool has no answer for.
usePackCookies()
usePackCookies() from @pack/hydrogen still works, but is deprecated. From
4.0 it only records analytics consent, and only when you pass hasUserConsent
and it differs from what Pack has recorded. Use setPackConsent() instead.
Reading consent in your theme
The root loader data from getPackContextData() includes:
packConsent— each purpose asgranted,deniedorunknownpackConsentAllowed— whether each purpose may be used, after the unanswered-visitor policy
On the server, the same answers are on context.pack.consent and
context.pack.consentAllowed.
Pack's cookies
| Cookie | Set by | Lasts | Holds | Depends on consent |
|---|---|---|---|---|
__pack | Your storefront's server (signed) | 360 days | Preview settings, and the visitor id with analytics consent | The visitor id: analytics |
__pack_test | Your storefront's server (signed) | 360 days | The tests and variants the visitor is in, and a random id used to assign them | No |
__pack_s | Your storefront's server | 30 min, rolling | Whether this visit started as a new or returning visitor | No |
__pack_exposed | The browser (see below) | 24 hours | Exposures already reported, so each is reported once | Written only when exposures may be reported (above) |
__pack_user_consent | Your storefront's server | 360 days | The visitor's consent answers | No — it records consent |
None of these are HttpOnly: scripts on the page can read them.
The random id in __pack_test lasts as long as the cookie and does not depend
on consent. Your storefront's server also writes __pack_exposed once, without
checking consent, when it converts the exposedTest cookie from an earlier
version.
Configuring GTM consent settings
- In GTM, require consent for:
ad_storageanalytics_storagepersonalization_storage
- When consent is denied, block all GA4 tags and A/B exposure events.
Preview vs. production
- Shopify's consent banner may only appear in production.
- For OneTrust QA in staging:
- Set
PUBLIC_ONETRUST_DATA_DOMAIN_SCRIPTin your.env. - Use a permanent staging URL and test scripts to validate flows.
- Set
Expected behaviors
- Consent declined (or not yet answered, when
unknownConsentis'denied'; by default an unanswered visitor is treated as consented)- No exposure events are reported.
- No
view_experimentevents in GTM or GA4. - The Pack visitor id changes on every request.
- Pack's analytics still records page views, with an id that changes daily.
- Consent granted
view_experimentappears in GTM Debug Mode and GA4 DebugView.- Experiment exposure data pushes to
window.dataLayer.
- Consent withdrawn mid-session
view_experimentevents stop.- The stored Pack visitor id is removed on the same response.
- BigQuery
- If consent was granted, the event appears in BigQuery within 24 hours.
- If denied, no experiment data is stored.
Before @pack/hydrogen 4.0
Earlier versions record only an analytics answer, written by usePackCookies,
and do not pass it to PackTestProvider. Your theme passes consent to
PackTestProvider itself, which holds exposures until hasUserConsent is true:
useEffect(() => {
function onConsentUpdate(evt: any) {
const hasFunctional = evt.detail.groups.includes('C0003');
setHasUserConsent(hasFunctional);
}
window.addEventListener('OneTrustGroupsUpdated', onConsentUpdate);
return () => window.removeEventListener('OneTrustGroupsUpdated', onConsentUpdate);
}, []);
- Listen to
OneTrustGroupsUpdatedandvisitorConsentCollected. - If you use a different consent provider, update the event names accordingly.
This keeps working on 4.0: a hasUserConsent you pass always wins over Pack's
answer for exposures.