> ## 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 Next.js

> Set up the ABConvert headless SDK on a Next.js App Router storefront in five steps.

This guide sets up the headless SDK on a Next.js storefront, with test groups assigned in the middleware. 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.

The SDK supports the App Router on Next.js 14.2.10 and later 14.2 releases, 15.1 to 15.4, and 16. Earlier 14 releases, 15.0, and 15.5 are not supported.

## How it works

Next.js caches the pages it renders. A page a price test changes, such as a product page with a test price, needs one cached copy per test group. The SDK's middleware works out the visitor's test groups and rewrites a tested page to a route named for them, the **page key**, so Next.js keeps one copy per combination of test groups and serves each visitor the right one. The address bar, your links, and search engines keep seeing the clean URL. Only a price test enters a page key: shipping, offer, and checkout tests apply in checkout, and a URL redirect test redirects before any rewrite. [Next.js caching and CDN](/headless-sdk/nextjs-caching) explains the mechanism and the CDN options; you need none of it to set up.

## Setup

### Step 1: Install the package and add the middleware

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

Keep the SDK's options in one file. The middleware, your layouts, and your cart action all use them.

```ts lib/abconvert.ts theme={null}
export const abconvertOptions = {
  shop: 'your-store.myshopify.com',
  storefrontKey: 'abc_pk_4f9c2e7a1b3d5f6e8a0c2b4d6f8e0a1c',
};
```

The middleware assigns the visitor, sets the SDK's cookies, redirects for a [URL redirect test](/headless-sdk/url-redirects), and rewrites tested pages. Keep route handlers such as webhooks, and anything under `/api`, out of its matcher.

<Tabs>
  <Tab title="Next.js 14 and 15">
    ```ts middleware.ts theme={null}
    import type { NextRequest } from 'next/server';
    import { abconvertMiddleware } from '@abconvert/headless/next';
    import { abconvertOptions } from './lib/abconvert';

    export function middleware(request: NextRequest) {
      return abconvertMiddleware(request, abconvertOptions);
    }

    export const config = { matcher: ['/((?!api/|_next/|favicon.ico|robots.txt|sitemap.xml).*)'] };
    ```
  </Tab>

  <Tab title="Next.js 16">
    ```ts proxy.ts theme={null}
    import type { NextRequest } from 'next/server';
    import { abconvertMiddleware } from '@abconvert/headless/next';
    import { abconvertOptions } from './lib/abconvert';

    export function proxy(request: NextRequest) {
      return abconvertMiddleware(request, abconvertOptions);
    }

    export const config = { matcher: ['/((?!api/|_next/|favicon.ico|robots.txt|sitemap.xml).*)'] };
    ```
  </Tab>
</Tabs>

<Warning>
  Do not add the `missing` rule for the `next-router-prefetch` header that Next.js's own middleware guide shows. With it, a link's prefetch skips the middleware, and the click shows the original prices from that prefetched copy.
</Warning>

### Step 2: Add the tested routes

Put the pages a price test can change under `app/abconvert/[pageKey]/`, and keep them at their usual routes too: the home page, collection and search pages, and product pages. The middleware sends a visitor to the page key route only when a test changes that page for them. The cart and account pages are never rewritten.

```text theme={null}
app/
├── layout.tsx                    <html> and <body>, no provider: it has no page key
├── (store)/                      pages no test changes for this visitor
│   ├── layout.tsx                provider with the empty page key
│   ├── page.tsx
│   ├── products/[handle]/page.tsx
│   ├── collections/[handle]/page.tsx
│   ├── search/page.tsx
│   └── cart/page.tsx
└── abconvert/[pageKey]/          pages a test changes
    ├── layout.tsx                provider with the page key from the URL
    ├── page.tsx
    ├── products/[handle]/page.tsx
    ├── collections/[handle]/page.tsx
    └── search/page.tsx
```

Each route group's layout creates the client from its page key and hands the page state to `ABConvertProvider`, the SDK's React context provider. In the browser it reads the visitor from their cookies and tracks for them, and every client component under it reads the client through `useABConvert()`, so no `'use client'` file of your own is needed.

<CodeGroup>
  ```tsx app/abconvert/[pageKey]/layout.tsx theme={null}
  import { ABConvertProvider } from '@abconvert/headless/react';
  import { createABConvert } from '@abconvert/headless/next';
  import { abconvertOptions } from '@/lib/abconvert';

  export default async function PageKeyLayout({ children, params }: { children: React.ReactNode; params: Promise<{ pageKey: string }> }) {
    // params is a promise from Next.js 15 on; awaiting it also works on 14
    const { pageKey } = await params;
    // Reads no request, so the pages under this layout stay static
    const abconvert = await createABConvert({ ...abconvertOptions, pageKey });

    return <ABConvertProvider pageState={abconvert.getPageState()}>{children}</ABConvertProvider>;
  }
  ```

  ```tsx app/(store)/layout.tsx theme={null}
  import { ABConvertProvider } from '@abconvert/headless/react';
  import { createABConvert } from '@abconvert/headless/next';
  import { abconvertOptions } from '@/lib/abconvert';

  export default async function StoreLayout({ children }: { children: React.ReactNode }) {
    const abconvert = await createABConvert({ ...abconvertOptions, pageKey: '' });

    return <ABConvertProvider pageState={abconvert.getPageState()}>{children}</ABConvertProvider>;
  }
  ```
</CodeGroup>

A page is one component under both routes. Each route file passes its params and keeps its own segment config, which Next.js reads from the route file itself.

<CodeGroup>
  ```tsx components/ProductPage.tsx theme={null}
  import { createABConvert } from '@abconvert/headless/next';
  import { abconvertOptions } from '@/lib/abconvert';
  import { ProductPrice } from '@/components/ProductPrice';
  import { AddToCartButton } from '@/components/AddToCartButton';

  export async function ProductPage({ handle, pageKey = '' }: { handle: string; pageKey?: string }) {
    // With the empty page key, every price method returns null: the original price
    const abconvert = await createABConvert({ ...abconvertOptions, pageKey });
    const product = await getProduct(handle);
    const productVariant = product.selectedOrFirstAvailableVariant;

    // No test price: use the Storefront API price
    const price = abconvert.getPriceByVariantId(productVariant.id);
    const amount = price ? price.amount : Number(productVariant.price.amount);

    return (
      <article>
        <h1>{product.title}</h1>
        <ProductPrice productVariant={productVariant} price={price} label={abconvert.formatPrice(amount, productVariant.price.currencyCode)} />
        <AddToCartButton variantId={productVariant.id} />
      </article>
    );
  }
  ```

  ```tsx app/abconvert/[pageKey]/products/[handle]/page.tsx theme={null}
  import { ProductPage } from '@/components/ProductPage';

  export const revalidate = 60;
  // Render each page key on its first request, then cache it
  export const generateStaticParams = () => [];

  export default async function Page({ params }: { params: Promise<{ pageKey: string; handle: string }> }) {
    return <ProductPage {...(await params)} />;
  }
  ```

  ```tsx app/(store)/products/[handle]/page.tsx theme={null}
  import { ProductPage } from '@/components/ProductPage';

  export const revalidate = 60;

  export default async function Page({ params }: { params: Promise<{ handle: string }> }) {
    return <ProductPage {...(await params)} />;
  }
  ```
</CodeGroup>

Keep `revalidate` on both route files so the page stays static, with one cached copy per page key. `ProductPrice` and `AddToCartButton` are the client components from [Step 4](#step-4-track-page-views-add-to-cart-and-exposures); they record what the visitor saw and did.

### Step 3: Write the cart attributes

Checkout applies a test only when the cart carries the visitor's test groups. Your add-to-cart server action creates the client without `pageKey`, so it reads the visitor from the request. It adds the line with the SDK's line attributes, then writes the SDK's cart attributes with the cart's own:

```ts app/actions.ts theme={null}
'use server';
import { createABConvert } from '@abconvert/headless/next';
import { abconvertOptions } from '@/lib/abconvert';

export async function addToCart(form: FormData) {
  const abconvert = await createABConvert(abconvertOptions);
  const cartId = await getCartId();
  const merchandiseId = String(form.get('variantId'));

  // Add the line, with ABConvert's line attributes. The mutation selects the cart's attributes { key value }
  const { cartLinesAdd } = await storefront.mutate(CART_LINES_ADD, {
    variables: { cartId, lines: [{ merchandiseId, quantity: 1, attributes: abconvert.getCartLineAttributes() }] },
  });
  if (cartLinesAdd.userErrors.length) return { errors: cartLinesAdd.userErrors };

  // Keep the cart's attributes and add or replace ABConvert's
  const ours = abconvert.getCartAttributes();
  const keys = new Set(ours.map(({ key }) => key));
  const { cartAttributesUpdate } = await storefront.mutate(CART_ATTRIBUTES_UPDATE, {
    variables: {
      cartId,
      attributes: [
        ...cartLinesAdd.cart.attributes.filter(({ key }) => !keys.has(key)).map(({ key, value }) => ({ key, value: value ?? '' })),
        ...ours,
      ],
    },
  });
  if (cartAttributesUpdate.userErrors.length) return { errors: cartAttributesUpdate.userErrors };
  return { ok: true };
}
```

Run the same attribute update on your cart page when the attributes changed. [Connect the cart](/headless-sdk/cart) covers that, 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 4: Track page views, add to cart, and exposures

Next.js has no built-in storefront events, so your client components call `track` for them, and `trackExposure` once a test price is on screen. Render `TrackPageView` once in each route group's layout, inside the provider.

<CodeGroup>
  ```tsx components/TrackPageView.tsx theme={null}
  'use client';
  import { useEffect } from 'react';
  import { usePathname } from 'next/navigation';
  import { useABConvert } from '@abconvert/headless/react';

  export function TrackPageView() {
    const { track } = useABConvert();
    const pathname = usePathname();

    // Every page load and client-side route change
    useEffect(() => {
      track('page_viewed');
    }, [pathname]);

    return null;
  }
  ```

  ```tsx components/ProductPrice.tsx theme={null}
  'use client';
  import { useEffect } from 'react';
  import type { Price } from '@abconvert/headless';
  import { useABConvert } from '@abconvert/headless/react';

  export function ProductPrice({ productVariant, price, label }: { productVariant: { id: string; product: { id: string } }; price: Price | null; label: string }) {
    const { track, trackExposure } = useABConvert();

    useEffect(() => {
      // The product variant is on screen
      track('product_viewed', { productId: productVariant.product.id, variantId: productVariant.id });
      // The visitor has seen the test price
      if (price) trackExposure(price.experimentId);
    }, [productVariant.id, price?.experimentId]);

    return <span>{label}</span>;
  }
  ```

  ```tsx components/AddToCartButton.tsx theme={null}
  'use client';
  import { useABConvert } from '@abconvert/headless/react';
  import { addToCart } from '@/app/actions';

  export function AddToCartButton({ variantId }: { variantId: string }) {
    const { track } = useABConvert();

    return (
      <form
        action={async (form: FormData) => {
          const result = await addToCart(form);
          // Track only a line that was added
          if ('errors' in result) return;
          track('product_added_to_cart', { variantId, quantity: 1 });
        }}
      >
        <input type="hidden" name="variantId" value={variantId} />
        <button type="submit">Add to cart</button>
      </form>
    );
  }
  ```
</CodeGroup>

See [Tracking and cookies](/headless-sdk/tracking) for offer exposures, custom events, and consent.

### 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

* **Creating the client without `pageKey` in a layout or page.** It reads the request, so the page renders per request and Next.js caches none of them. The fix: pass `pageKey` in layouts and pages, and create the client from the request only where you need the visitor, such as your cart action.
* **Calling a cart method on a client made with `pageKey`.** That client has no visitor, so `getCartAttributes()`, `getCartLineAttributes()`, and `getCheckoutUrl()` throw. The fix: create the client without `pageKey` in a server action or route handler. In the browser, `useABConvert()` reads the visitor from their cookies, so `getCheckoutUrl()` works in a client component.
* **Rendering the provider in the root layout.** It has no page key, so the page key route's provider nests under one with the empty key. The fix: one provider per route group, as in [Step 2](#step-2-add-the-tested-routes).
* **Building canonical URLs from the rewritten path.** The canonical then points to `/abconvert/...`, which redirects. The fix: build canonical URLs, `hreflang` links, and structured data from your route params, such as `handle`. `usePathname()` in a client component already returns the URL in the address bar.
* **Replacing the middleware's `cache-control: private`.** A proxy or CDN rule that sets `public` on every product page makes your CDN serve one test group's prices to every visitor. The fix: keep `private` on every response that has it. See [Next.js caching and CDN](/headless-sdk/nextjs-caching#how-tested-pages-are-cached).

## Next steps

* [Render the test price](/headless-sdk/prices) on collection cards, search, and the cart too.
* [Replace buttons that skip the cart](/headless-sdk/cart#express-checkout) with a checkout URL or a "Buy now" cart.
* Read [Next.js caching and CDN](/headless-sdk/nextjs-caching) if you have middleware of your own, refresh product pages on demand, or want your CDN to cache tested pages.
* 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.