Adding A/B Testing to Your Hydrogen Storefront

This guide will walk you through setting up Pack's A/B testing feature on your Hydrogen storefront to deliver flicker-free test experiences with clean data integration.

Prerequisites

Before implementing Pack's A/B testing, ensure your data infrastructure is ready:

Required Data Infrastructure:

  • BigQuery connection (BigQuery, Snowflake, Databricks, etc.)
  • Clean data collection platform (Elevar, Fueled, Blotout, Littledata, or similar)
  • Server-side event tracking with proper attribution
  • Analytics governance and established KPI definitions

Technical Requirements:

  • Hydrogen storefront running on Shopify
  • Access to your storefront's codebase
  • BigQuery connection for test reporting
  • Analytics platform integration (GA4, etc.)

1. Ensure Required Package Versions

Ensure you have the following minimum package versions installed:

@pack/hydrogen 3.2.6 stops re-rolling a visitor's variant when their test session expires, which previously could count the same visitor under more than one variant. @pack/react 4.2.0 adds the packDebug helpers used to inspect assignments in the browser.

2. Update server.ts and add testSession to createPackClient

  import {createPackClient, PackSession, PackTestSession, handleRequest} from '@pack/hydrogen'; // Import PackTestSession
  // other imports and code...
  const [cache, session, packSession, testSession] = await Promise.all([ // Add testSession
    caches.open('hydrogen'),
    AppSession.init(request, [env.SESSION_SECRET]),
    PackSession.init(request, [env.SESSION_SECRET]),
    PackTestSession.init(request, [env.SESSION_SECRET]), // Add this line
  ]);

  const pack = createPackClient({
    cache,
    waitUntil,
    token: env.PACK_SECRET_TOKEN,
    storeId: env.PACK_STOREFRONT_ID,
    session: packSession,
    contentEnvironment: env.PUBLIC_PACK_CONTENT_ENVIRONMENT,
    defaultThemeData,
    testSession, // Add this line
  });

3. Add PackTestProvider to Layout.tsx

PackTestProvider reports test exposures. Wrap your layout in it, and pass it the exposure handler from the useTestExpose hook you add in the next step:

app/components/Layout.tsx

import type { ReactNode } from 'react';
import { PackTestProvider } from '@pack/hydrogen';

import { useTestExpose } from '~/hooks/useTestExpose';
// ...your layout's other imports (Analytics, Header, Footer, and so on)

export function Layout({ children }: { children: ReactNode }) {
  const { handleTestExpose } = useTestExpose();

  return (
    <>
      <Analytics />
      <PackTestProvider testExposureCallback={handleTestExpose}>
        <div>
          <Header />
          <main>{children}</main>
          <Footer />
          <Cart />
          <Search />
          <Modal />
        </div>
      </PackTestProvider>
    </>
  );
}

4. Add the useTestExpose hook

The useTestExpose hook sends each exposure to analytics. It doesn't check consent: PackTestProvider only calls it once the visitor has given analytics consent (see Consent below). AnalyticsEvent.EXPERIMENT_EXPOSED is added to your analytics constants in step 6.

app/hooks/useTestExpose.ts

import { useCallback, useEffect, useRef } from 'react';
import { useAnalytics } from '@shopify/hydrogen';

import { AnalyticsEvent } from '~/components/Analytics/constants';

export function useTestExpose() {
  const { publish } = useAnalytics();

  // Keep the latest publish function for the delayed call below
  const publishRef = useRef(publish);
  useEffect(() => {
    publishRef.current = publish;
  }, [publish]);

  const handleTestExpose = useCallback((test: any) => {
    console.log('==== TEST EXPOSED ====', test);

    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push({
      event: 'view_experiment',
      experiment_name: test.handle,
      experiment_variation: test.testVariant.handle,
    });

    // Analytics subscribers may not be registered yet on the first page load.
    setTimeout(() => {
      publishRef.current?.(AnalyticsEvent.EXPERIMENT_EXPOSED, test);
    }, 1000);
  }, []);

  return { handleTestExpose };
}

Features

  • ✅ Publishes EXPERIMENT_EXPOSED events for each reported exposure.
  • ✅ Pushes experiment exposure data to window.dataLayer for GTM tracking.
  • ✅ Uses a useRef to maintain the latest publish function from useAnalytics.

Consent

PackTestProvider reports exposures only while the visitor has analytics consent. Until then it holds them on the current page. From @pack/hydrogen 4.2, an A/B exposure still held when the visitor leaves the page is offered again on a later page where the test shows, within 24 hours of assignment.

Without a hasUserConsent prop, PackTestProvider follows the consent Pack records for the visitor, which for Shopify's privacy banner needs no code. It reads that answer from your root loader, so:

  • The root loader must return ...pack.getPackContextData(). Without it, no exposures are reported.
  • This needs @pack/hydrogen 4.0 or later. On 3.x, pass hasUserConsent yourself (see Before @pack/hydrogen 4.0).
  • If you have visitors from the EU or UK, set unknownConsent. By default a visitor who hasn't answered the banner counts as consenting, and every visitor's first page view is unanswered. So without it, an exposure on that first page is reported before they've seen the banner. unknownConsent takes a function, so you can treat unanswered visitors as not consenting only in the countries that need a banner. See "Visitors who have not answered" under What consent controls.
  • Using OneTrust or another consent tool? See Other consent tools.

Reporting exposures

Pack records each exposure it hands to testExposureCallback as reported, in the __pack_exposed cookie, and won't offer it again. So:

  • Don't check consent inside the callback. A callback that skips sending still marks the exposure as reported, and it is lost. Leave consent to PackTestProvider.
  • Return false if you didn't send it. From @pack/hydrogen 4.1, Pack then doesn't record it as reported, and offers it again: a personalization on the next page load, and from 4.2 an A/B test on a later page where it shows, within 24 hours of assignment. On 4.1 a declined A/B exposure isn't retried. Any other return value, including a Promise, counts as sent.
  • Send it promptly. The 1-second delay above happens after Pack has recorded the exposure, so a visitor who leaves within that second is recorded but not sent.

If you pass hasUserConsent yourself, pass true or false. From 4.1, an explicit undefined holds exposures until it becomes a boolean.


5. Add PackTestRoute to All Relevant Storefront Routes

Integrate PackTestRoute to register test-related routes in your storefront (e.g., ($locale)._index.tsx, ($locale).articles.$handle.tsx, ($locale).blogs.$handle.tsx, ($locale).collections.$handle.tsx, ($locale).pages.$handle.tsx, ($locale).products.$handle.tsx).

Import PackTestRoute Component

import { PackTestRoute } from '@pack/hydrogen';

Add PackTestRoute to Your Routes

For example, in app/routes/($locale)._index.tsx:

app/routes/($locale)._index.tsx

import { useLoaderData } from 'react-router';
import { RenderSections } from '@pack/react';
import { PackTestRoute } from '@pack/hydrogen';

import { PAGE_QUERY } from '~/data/graphql/pack/page';

import type { Route } from './+types/($locale)._index';

export async function loader({ context, request }: Route.LoaderArgs) {
  const { data, packTestInfo } = await context.pack.query(PAGE_QUERY, {
    variables: { handle: '/' },
    cache: context.storefront.CacheLong(),
  });

  if (!data?.page) throw new Response(null, { status: 404 });

  return {
    page: data.page,
    url: request.url,
    packTestInfo,
  };
}

export default function Index() {
  const { page } = useLoaderData<typeof loader>();

  return (
    <>
      <PackTestRoute />
      <RenderSections content={page} />
    </>
  );
}

PackTestRoute reads packTestInfo from the route's loader data, so each loader must return the packTestInfo value from context.pack.query.

From @pack/hydrogen 4.0, packTestInfo can hold several tests when a query reports exposures for more than one. Keep returning it as above: PackTestRoute reads both forms, so no change is needed.

Impression triggers

Each test has an Impression trigger setting in the Pack admin that controls when an impression (exposure) is counted. PackTestRoute handles both modes:

  • On page load (ON_LOAD, the default): the impression fires as soon as PackTestRoute mounts on a route whose loader returned packTestInfo.
  • On element view (ON_ELEMENT_VIEW): the impression fires only once a test element is at least 10% visible in the viewport. By default PackTestRoute observes section[data-comp-id="…"] for each section in the visitor's variant. Since @pack/hydrogen 3.2.1, visitors in a control variant with no sections of its own are tracked against the sections used by the other variants, so control impressions are counted too.

If no matching element is in the DOM yet, PackTestRoute watches for it with a MutationObserver. After 30 seconds without a match it stops and logs a warning ([Pack Test] Impression target not found within 30s for test "…"), and no impression is recorded for that page view. If the browser doesn't support IntersectionObserver, the impression falls back to firing on page load.

For code-driven tests, or when the element to watch isn't a Pack section, pass impressionSelector to override the default selectors:

  • Name
    impressionSelector
    Type
    string | string[] | ((packTestInfo) => string | string[] | undefined)
    Description

    CSS selector(s) to observe for ON_ELEMENT_VIEW tests. A function receives the current test (id, handle, testVariant) so you can return a different selector per test or variant. Returning undefined falls back to the default section selectors. Ignored for ON_LOAD tests.

Custom impression selector

<PackTestRoute
  impressionSelector={(test) =>
    test.handle === 'pdp-price-test' ? '[data-test="price-block"]' : undefined
  }
/>

6. Update Analytics Component and Relevant Integrations

Add an event for test exposures, then send it from each analytics integration you use. These examples follow the Blueprint's app/components/Analytics folder.

Add EXPERIMENT_EXPOSED to Analytics Constants

Update app/components/Analytics/constants.ts:

export const AnalyticsEvent = {
  ...HydrogenAnalyticsEvent,
  PRODUCT_VARIANT_SELECTED: 'custom_product_variant_selected',
  PRODUCT_ITEM_CLICKED: 'custom_product_item_clicked',
  PRODUCT_QUICK_SHOP_VIEWED: 'custom_product_quick_shop_viewed',
  CUSTOMER: 'custom_customer',
  CUSTOMER_SUBSCRIBED: 'custom_customer_subscribed',
  CUSTOMER_LOGGED_IN: 'custom_customer_logged_in',
  CUSTOMER_REGISTERED: 'custom_customer_registered',
  // New event for A/B testing experiment exposure
  EXPERIMENT_EXPOSED: 'custom_experiment_exposed',
} as typeof HydrogenAnalyticsEvent & {
  PRODUCT_VARIANT_SELECTED: 'custom_product_variant_selected';
  PRODUCT_ITEM_CLICKED: 'custom_product_item_clicked';
  PRODUCT_QUICK_SHOP_VIEWED: 'custom_product_quick_shop_viewed';
  CUSTOMER: 'custom_customer';
  CUSTOMER_SUBSCRIBED: 'custom_customer_subscribed';
  CUSTOMER_LOGGED_IN: 'custom_customer_logged_in';
  CUSTOMER_REGISTERED: 'custom_customer_registered';
  EXPERIMENT_EXPOSED: 'custom_experiment_exposed';
};

Add experimentExposedEvent to Integrations

For example, in app/components/Analytics/GA4Events:

// app/components/Analytics/GA4Events/events.ts

const experimentExposedEvent = ({
  debug,
  ...data
}: Record<string, any> & { debug?: boolean }) => {
  const analyticsEvent = AnalyticsEvent.EXPERIMENT_EXPOSED;

  try {
    if (debug) logSubscription({ data, analyticsEvent });

    const { test, customer } = data;

    if (!test) throw new Error('`test` parameter is missing.');

    const event = {
      event: 'view_experiment',
      user_properties: generateUserProperties({ customer }),
      experiment_id: test?.id,
      experiment_name: test?.handle,
      experiment_variant_id: test?.testVariant.id,
      experiment_variation: test?.testVariant.handle,
    };

    emitEvent({ event, debug });
  } catch (error) {
    logError({
      analyticsEvent,
      message: error instanceof Error ? error.message : error,
    });
  }
};

export {
  experimentExposedEvent,
  // ... additional exports
};

And in app/components/Analytics/GA4Events/GA4Events.tsx:

import { useEffect, useState } from 'react';

import { AnalyticsEvent } from '../constants';
import {
  experimentExposedEvent, // newly added
  ANALYTICS_NAME,
} from './events';

export function GA4Events({ ga4TagId, register, subscribe, customer, debug = false }) {
  // ... initialization and script loading logic

  useEffect(() => {
    if (!scriptLoaded) return;

    subscribe(AnalyticsEvent.EXPERIMENT_EXPOSED, (data: Data) => {
      experimentExposedEvent({ ...data, customer, debug });
    });

    // ... additional subscriptions
  }, [scriptLoaded, customer?.id, debug]);

  // ... rest of the code
}

7. Verification Steps

  1. Run the storefront locally.
  2. Visit an A/B test URL (e.g., /test-xyz) with an active test set up in Pack Admin – A/B Tests.
  3. Confirm that handleTestExpose is firing. You should see the console log:
    ==== TEST EXPOSED ====
    
  4. Check GTM Debug Mode and GA4 DebugView to validate event tracking.

How assignment works

Pack assigns visitors to a test server-side, inside context.pack.query, and stores the assignment in the signed __pack_test cookie. A few rules keep assignments stable and your results clean:

  • Assignments are sticky. A visitor's test session has a 4-hour expiry, but since @pack/hydrogen 3.2.6 an assignment that is still eligible is kept (and its expiry refreshed) when the session expires. A visitor is only re-bucketed when their test is paused or removed, or their variant is removed.
  • Prefetches never assign. Since @pack/hydrogen 3.2.3, requests with a Sec-Purpose, Purpose, or X-Purpose header containing prefetch skip test assignment, so hovering a link doesn't bucket a visitor into a test on a page they never visit.
  • Preview and forced tests don't assign. Since @pack/hydrogen 3.2.4, preview mode and URLs that force a test with query parameters don't resolve or store a natural assignment. Since 3.2.5, the previewed test and variant are exposed on pack.abTest (and getPackContextData().packAbTest), so code-driven variants render in the customizer and preview links the same way they do on the live site.

Audience targeting

Since @pack/hydrogen 3.3.0, tests can be limited to an audience. Audience rules are evaluated at SSR during assignment, and a visitor is only eligible for a test if they match its audience. If an audience can't be evaluated, the visitor is treated as not matching (fails closed), so they see the default experience instead of an unintended variant. See Audiences for how to configure them.


Debugging with packDebug

@pack/react 4.2.0 and later adds a window.packDebug object in the browser. It is loaded automatically by PackProvider, so no setup is needed. The helpers read the __pack_test cookie and return null (or []) when there is nothing to report.

Browser console

window.packDebug.getTestInfos()
// [{ test: 'homepage-hero', variant: 'variant-b', expiresAt: '2026-09-25T18:00:00.000Z' }]

window.packDebug.getTestInfo()
// { test: 'homepage-hero', variant: 'variant-b', expiresAt: '2026-09-25T18:00:00.000Z' }

window.packDebug.getTestRulesCacheInfo()
// { rulesCount: 2, cachedAt: '2026-09-25T14:00:00.000Z', ageSeconds: 312, isFresh: true }
  • Name
    getTestInfos()
    Type
    Array<{ test, variant, expiresAt }>
    Description

    Every test the visitor is in, oldest first: the test handle, the variant handle, and the session expiry as an ISO string (or null). Added in @pack/react 4.6.0.

  • Name
    getTestInfo()
    Type
    { test, variant, expiresAt } | null
    Description

    The visitor's oldest assignment, in the same shape. Returns null if the visitor isn't in a test. A visitor can be in several tests, so prefer getTestInfos().

  • Name
    getTestRulesCacheInfo()
    Type
    { rulesCount, cachedAt, ageSeconds, isFresh } | null
    Description

    The test rules cached in the visitor's session: how many rules are cached, when they were cached, their age in seconds, and whether they are still within the 1-hour cache TTL.


You can refer to the Consent Management documentation for more details on how to manage user consent effectively.


GA4 / GTM Integration

Please refer to GTM & GA4 Integration.


QA & Testing Plan

QA Test Cases

Functional Cookie Consent Testing:

Test Case IDScenarioExpected Result
C-01First-time visitor declines consentview_experiment event does not fire. No ==== TEST EXPOSED ==== log appears.
C-02User grants consentview_experiment event fires in GTM. Console log ==== TEST EXPOSED ==== appears.
C-03User revokes consentNo further view_experiment events fire. No additional ==== TEST EXPOSED ==== logs.
C-04User with existing consent revisitsview_experiment event fires normally. Console log ==== TEST EXPOSED ==== appears.

Event Debugging & Validation

GTM Event Validation

Test Case IDScenarioExpected Result
GTM-01Consent not givenview_experiment event does not appear in GTM Preview Mode. No ==== TEST EXPOSED ==== log.
GTM-02Consent givenview_experiment event fires, visible in GTM Preview Mode. Console log appears.
GTM-03User toggles consent from denied to allowed mid-sessionConsent status updates; subsequent view_experiment events fire correctly with matching logs.
GTM-04GTM Consent Settings block analytics without consentGA4 events do not fire when consent is denied. No ==== TEST EXPOSED ==== log appears.

GA4 Debugging & Validation

Test Case IDScenarioExpected Result
GA4-01User grants consent and views an experimentview_experiment event appears in GA4 DebugView with correct experiment details and console log.
GA4-02User does not grant consentview_experiment event does not appear in GA4 DebugView; no console log.
GA4-03User toggles consent on and offGA4 logs events only when consent is active and stops when revoked; console log behavior matches.
GA4-04GA4 BigQuery exportBigQuery tables update daily with the correct event parameters.

BigQuery Data Validation

Test Case IDScenarioExpected Result
BQ-01User grants consent and view_experiment firesThe event is recorded in BigQuery within 24 hours.
BQ-02User does not grant consent and visits experimentNo experiment-related data is stored in BigQuery.
BQ-03Data Layer Variables are missingBigQuery records should not contain null values for required parameters.

Debugging Tools

  • Console Log: Look for the ==== TEST EXPOSED ==== message.
  • GTM Debug Mode: Append ?gtm_debug=x to the URL.
  • GA4 DebugView: Accessible via Admin > DebugView in GA4.
  • Chrome DevTools: Inspect Console > Network > Analytics Requests.
  • BigQuery: Run queries like:
    SELECT * FROM analytics_GA4PROPERTYID.events_* ORDER BY event_timestamp DESC
    

Reporting & Fixing Issues

  1. Verify that the visitor has analytics consent, and that your root loader returns ...pack.getPackContextData().
  2. Ensure handleTestExpose is triggered correctly.
  3. Confirm GTM receives the view_experiment event.
  4. Check that GA4 logs the event.
  5. Debug in BigQuery if GA4 logs but data isn’t received.

Summary

  • ✅ ==== TEST EXPOSED ==== appears when an experiment is correctly logged.
  • ✅ GTM Preview Mode shows view_experiment when consent is granted.
  • ✅ GA4 DebugView confirms event logging.
  • ✅ BigQuery records event data daily.

QA OPEN ITEM

  • Testing cookie consent in preview environments is challenging.
    • Shopify’s consent banner may only fire in production.
    • OneTrust requires a permanent staging URL and test scripts for QA before production.

Troubleshooting

Common Issues:

Test events missing in BigQuery:

  • Verify GA4 is tracking events on your storefront (check GA4 real-time reports)
  • Confirm GA4 → BigQuery linking is enabled in GA4 Admin
  • Double-check BigQuery credentials in your Pack dashboard
  • Wait 24-48 hours for data to appear (BigQuery export takes time)

Inconsistent test assignment:

  • Pack components not implemented correctly in your React code
  • Conflicting A/B testing code running alongside Pack
  • User identification issues across sessions
  • Outdated/incorrect @pack/react @pack/hydrogen packages

Data discrepancies between platforms:

  • Make sure events aren't being sent twice (client + server)
  • Align attribution windows (GA4 vs Shopify vs Other data sources)
  • Pick one platform as your "source of truth" for each metric

Performance issues:

  • Ensure A/B test logic runs server-side
  • Avoid client-side variant switching (causes layout shifts)
  • Check for unnecessary JavaScript loading that you may have added for the test

Getting Help:

Pack handles experience delivery, while your data partners handle measurement accuracy. For:

  • Experience delivery issues: Contact Pack support
  • Data discrepancies or attribution issues: Work with your analytics provider (Elevar, Fueled, etc.)
  • BigQuery connection problems: Check with your data infrastructure team

Best Practices

  1. Clean Event Tracking: Ensure consistent event naming and data structure
  2. Proper Fallbacks: Always include fallback experiences for error cases
  3. Performance Monitoring: Track test impact on page load times
  4. Data Validation: Regularly verify data accuracy across all platforms

Next Steps

Once your A/B testing is set up:

  1. Start with simple tests to validate your data flow
  2. Run A/A tests to establish baseline performance
  3. Scale gradually to more complex test experiences
  4. Document learnings and share insights across your team

Need help with data infrastructure setup or connecting with analytics partners? Contact our team.

Remember: Pack delivers the experience, your analytics stack measures the results. This separation ensures you get both great user experiences and reliable data.

Guides

Consent Management

Learn how to manage user consent for A/B testing and analytics tracking.

Read more

GTM & GA4 Integration

Integrate Google Tag Manager and Google Analytics 4 with Pack for A/B testing.

Read more

Was this page helpful?