> ## Documentation Index
> Fetch the complete documentation index at: https://docs.abconvert.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Headless SDK on Hydrogen

> Set up the ABConvert headless SDK on a Hydrogen storefront in five steps.

This guide sets up the headless SDK on a Hydrogen storefront, with test groups assigned on the server. When you finish, every page shows the visitor their test group's prices, checkout charges them, and your results fill.

Read [Before you start](/headless-sdk/overview#before-you-start) first.

Two steps depend on the `@shopify/hydrogen` version in your `package.json`, because Hydrogen 2026.10 changed its analytics and moved cart changes to the browser. Each of those steps has a tab per version.

## Setup

### Step 1: Install the package

```bash theme={null}
npm install @abconvert/headless
```

### Step 2: Create the client on the server

Import `createABConvert` from `@abconvert/headless/hydrogen`, on Hydrogen 2024.4 or later. It tracks nothing until the visitor answers Shopify's cookie banner, and sets a cookie only when one changed, such as on a first visit.

```ts Server theme={null}
// app/lib/abconvert.server.ts
import { createABConvert } from '@abconvert/headless/hydrogen';

export function getABConvert(request: Request) {
  return createABConvert({
    shop: 'your-store.myshopify.com',
    storefrontKey: 'abc_pk_4f9c2e7a1b3d5f6e8a0c2b4d6f8e0a1c',
    request,
  });
}
```

### Step 3: Load it in the root loader and pass it to the browser

The root loader creates the client, adds its cookies to the response, and returns its page state:

```tsx Server (loader) theme={null}
// app/root.tsx
import { getABConvert } from '~/lib/abconvert.server';

export async function loader({ request }: LoaderFunctionArgs) {
  const abconvert = await getABConvert(request);

  // Set the SDK's cookies on the response
  const headers = new Headers();
  for (const cookie of abconvert.getSetCookieHeaders()) {
    headers.append('Set-Cookie', cookie);
  }

  return data(
    {
      // ...your existing cart, shop, and consent
      abconvert: abconvert.getPageState(),
    },
    { headers },
  );
}
```

A route that renders a test's data reads it in its loader and returns `getPageState()` too. The state then carries that test as the visitor saw it, and the route's component reads it through the provider:

```tsx Server (loader) theme={null}
// app/routes/products.$handle.tsx
export async function loader({ request, params }: LoaderFunctionArgs) {
  const abconvert = await getABConvert(request);
  const product = await loadProduct(params.handle);
  // Read every price the route renders, so the browser renders the same
  for (const variant of product.variants) abconvert.getPriceByVariantId(variant.id);
  return data({ product, abconvert: abconvert.getPageState() });
}
```

Then wrap your routes in the provider:

<Tabs>
  <Tab title="Hydrogen 2026.10 and later">
    Keep Hydrogen's `ShopifyScripts` in your document `<head>`, where Hydrogen's template already renders it.

    ```tsx Component theme={null}
    // app/root.tsx
    import { useMatches } from 'react-router';
    import type { PageState } from '@abconvert/headless';
    import { ABConvertProvider } from '@abconvert/headless/hydrogen';

    export default function App({ loaderData }: Route.ComponentProps) {
      // The deepest route's state, when it returned one
      const pageState =
        useMatches()
          .map((match) => (match.data as { abconvert?: PageState } | undefined)?.abconvert)
          .filter((state): state is PageState => Boolean(state))
          .pop() ?? loaderData.abconvert;
      return (
        <ABConvertProvider pageState={pageState}>
          <Outlet />
        </ABConvertProvider>
      );
    }
    ```
  </Tab>

  <Tab title="Hydrogen before 2026.10">
    Place the provider inside Hydrogen's `Analytics.Provider`:

    ```tsx Component theme={null}
    // app/root.tsx
    // On Hydrogen 2025.4 and earlier, import these from '@remix-run/react'
    import { useLoaderData, useMatches } from 'react-router';
    import type { PageState } from '@abconvert/headless';
    import { ABConvertProvider } from '@abconvert/headless/hydrogen/analytics-provider';

    export default function App() {
      const data = useLoaderData<typeof loader>();
      // The deepest route's state, when it returned one
      const pageState =
        useMatches()
          .map((match) => (match.data as { abconvert?: PageState } | undefined)?.abconvert)
          .filter((state): state is PageState => Boolean(state))
          .pop() ?? data.abconvert;

      return (
        <Analytics.Provider cart={data.cart} shop={data.shop} consent={data.consent}>
          <ABConvertProvider pageState={pageState}>
            <Outlet />
          </ABConvertProvider>
        </Analytics.Provider>
      );
    }
    ```
  </Tab>
</Tabs>

The provider listens to [Hydrogen's own analytics](https://shopify.dev/docs/storefronts/headless/hydrogen/analytics/tracking), so the SDK tracks page views, product views, and add to cart with no more code, and it reads the visitor's cookie consent from Shopify's [Customer Privacy API](https://shopify.dev/docs/api/customer-privacy), so nothing is sent before the visitor answers your banner. Hydrogen passes events on only after the visitor consents: on 2026.10 and later it keeps the events from before and passes them on then; before 2026.10 it passes on the current page view, and the product view on a product page, and an add to cart made before the answer is not counted.

<Warning>
  If your storefront sets a Content Security Policy, add ABConvert to its `connectSrc`, or the browser blocks everything the SDK tracks. Hydrogen's 2026.4 template sets one in `app/entry.server.tsx`:

  ```ts Server theme={null}
  const { nonce, header, NonceProvider } = createContentSecurityPolicy({
    shop: { checkoutDomain: context.env.PUBLIC_CHECKOUT_DOMAIN, storeDomain: context.env.PUBLIC_STORE_DOMAIN },
    connectSrc: ['https://app.abconvert.io'],
  });
  ```
</Warning>

### Step 4: Write the cart attributes

Checkout applies a test only when the cart carries the visitor's test groups.

<Tabs>
  <Tab title="Hydrogen 2026.10 and later">
    The cart changes through Hydrogen's cart store in the browser. Add ABConvert's cart line attributes to your add to cart form. The form imports `useProductForm` from the module where you call `createProductComponents`, and reads the attributes after the page loads, so a page in the [full-page cache](/headless-sdk/hydrogen-caching) stays the same for every visitor:

    ```tsx Component theme={null}
    // app/components/AddToCartForm.tsx
    import { useEffect, useState } from 'react';
    import type { CartAttribute } from '@abconvert/headless';
    import { useABConvert } from '@abconvert/headless/hydrogen';
    import { useProductForm } from '~/lib/product';

    export function AddToCartForm() {
      const { formProps, register } = useProductForm();
      const abconvert = useABConvert();
      const [lineAttributes, setLineAttributes] = useState<CartAttribute[]>([]);
      useEffect(() => setLineAttributes(abconvert.getCartLineAttributes()), [abconvert]);

      return (
        <form {...formProps()}>
          <input type="hidden" {...register('merchandiseId', {})} />
          <input {...register('quantity', { defaultValue: 1 })} />
          {lineAttributes.map(({ key, value }) => (
            <input key={key} type="hidden" {...register('attributeValue', { key, value })} />
          ))}
          <button {...register('addToCart', {})}>Add to cart</button>
        </form>
      );
    }
    ```

    Then render `ABConvertCartAttributes` once, inside both `CartProvider` and `ABConvertProvider`. It writes ABConvert's cart attributes on every page and after every cart change, keeps the cart's other attributes, and tries again when an update fails. Checkout charges what the cart attributes say, so hold your checkout button until `useCartReadyForCheckout` returns `true`. Pass both the `useCart` that `createCartComponents` returns:

    ```tsx Component theme={null}
    import { ABConvertCartAttributes, useCartReadyForCheckout } from '@abconvert/headless/hydrogen';
    <ABConvertCartAttributes useCart={useCart} /> // once, next to <Outlet /> in app/root.tsx
    const ready = useCartReadyForCheckout(useCart); // in your checkout button
    ```

    The button waits for a moment after the page loads and while an update runs. We trade that wait for the right price at checkout.
  </Tab>

  <Tab title="Hydrogen before 2026.10">
    The example uses [Hydrogen's cart handler](https://shopify.dev/docs/storefronts/headless/hydrogen/cart/customize-cart-handler).

    ```ts Server (cart action) theme={null}
    // app/routes/cart.tsx
    import { CartForm } from '@shopify/hydrogen';

    export async function action({ request, context }: ActionFunctionArgs) {
      const { cart } = context;
      const abconvert = await getABConvert(request);
      const { action, inputs } = CartForm.getFormInput(await request.formData());

      // Add ABConvert's attributes to each new line
      if (action === CartForm.ACTIONS.LinesAdd) {
        await cart.addLines(
          inputs.lines.map((line) => ({
            ...line,
            attributes: [...(line.attributes ?? []), ...abconvert.getCartLineAttributes()],
          })),
        );
      }

      // Add ABConvert's attributes to the cart, keeping the ones it has
      const current = await cart.get();
      const ours = abconvert.getCartAttributes();
      const keys = new Set(ours.map(({ key }) => key));
      const { userErrors } = await cart.updateAttributes([
        ...(current?.attributes ?? [])
          .filter(({ key }) => !keys.has(key))
          .map(({ key, value }) => ({ key, value: value ?? '' })),
        ...ours,
      ]);
    }
    ```

    Read `userErrors` as you do for any cart mutation. Run the same update in your cart page's loader when the attributes changed, so a test you launch after the visitor's last cart change reaches checkout too. See [On your cart page](/headless-sdk/cart#on-your-cart-page).
  </Tab>
</Tabs>

[Connect the cart](/headless-sdk/cart) covers express checkout and prices by country.

<Warning>
  This step is required. Without it, checkout charges the original price and the order is not counted in your test.
</Warning>

### Step 5: Confirm it works

Open a test's [preview link](/experiments/lifecycle#preview-before-you-launch) on your storefront with a [test group forced](/headless-sdk/preview#preview-a-test-group). You see that test group's version of the page.

Then follow [Check the integration](/headless-sdk/preview#check-the-integration) to confirm checkout applies the test.

<Note>
  Results need visitors. After you launch, give the test one to two weeks before you read them.
</Note>

## Common mistakes

* **Returning `getClientState()` from a loader.** It holds the visitor, so the route renders per visitor and cannot be cached. The fix: return `getPageState()`, as the loaders above do.
* **Opting a tested route into Oxygen's full-page cache.** The cache then serves one visitor's test price to everyone. The fix: call [`keepPrivateWhenTested`](/headless-sdk/hydrogen-caching) on that route.
* **Using `ShopPayButton` with product variant IDs.** It creates a checkout without your cart and accepts no attributes, so the test does not apply. The fix: [use a checkout URL or a "Buy now" cart](/headless-sdk/cart#express-checkout).

## Next steps

* [Render the test price](/headless-sdk/prices) everywhere a price appears.
* [Replace buttons that skip the cart](/headless-sdk/cart#express-checkout), such as `ShopPayButton`.
* Read [Hydrogen full-page cache](/headless-sdk/hydrogen-caching) if any route opts into Oxygen's full-page cache.
* Do the rest of [After setup](/headless-sdk/overview#after-setup).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.