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

# Next.js caching and CDN

> How the headless SDK keeps one cached copy per test group on Next.js, and how to fit it to your own middleware, product pages, revalidation, and CDN.

The [Next.js guide](/headless-sdk/nextjs) sets up the caching for you: Next.js keeps one copy of each tested page per test group, and you name no key and set no header. Read this page when your store has one of these: a CDN you want to cache tested pages, middleware of your own, product pages you refresh on demand, or a shipping bar or offer banner on cached pages. The first section explains how it works, for when you want to know.

## How tested pages are cached

Next.js caches a page once per path, and a CDN in front of it does the same. A product page in a price test needs one copy per test group instead. The SDK's middleware solves this with a **page key**: it works out which price tests change the page and which test group the visitor is in for each, and rewrites the request to a path that names them. Next.js then caches one copy per key, and your CDN never stores a tested page.

```mermaid theme={null}
%%{init: {"themeVariables": {
  "actorBkg": "#82C621",
  "actorBorder": "#82C621",
  "actorTextColor": "#0F1A05",
  "actorLineColor": "#9AA3AD",
  "signalColor": "#217BD1",
  "sequenceNumberColor": "#FFFFFF",
  "noteBkgColor": "#ACFF17",
  "noteBorderColor": "#ACFF17",
  "noteTextColor": "#0F1A05"
}}}%%
sequenceDiagram
    autonumber
    participant V as Browser
    participant C as Your CDN
    participant M as Middleware<br/>(SDK)
    participant N as Next.js cache
    participant P as Page

    V->>C: GET /products/blue-snowboard
    Note over C: A tested page is private:<br/>the CDN never stores it
    C->>M: Pass the request on
    Note over M: Decide the visitor's test groups<br/>and build the page key
    M->>N: Rewrite to /abconvert/<page key>/products/blue-snowboard
    alt A copy for this page key exists
        N-->>V: The cached copy
    else First visitor with this page key
        N->>P: Render with createABConvert({ pageKey })
        P-->>N: HTML, cached under the page key
        N-->>V: HTML
    end
    Note over V: The address bar still shows<br/>/products/blue-snowboard
```

A page key holds the price tests that change the page, the visitor's test group in each, the country the page is priced for, and a version of your tests, so an edit or a new launch is a new key. A visitor in Variant A of test 49603 and Control of test 50211, priced for the US, gets `v1k3z9q.cUS.49603-1.50211-0`; a visitor the second test excluded gets a shorter key with one segment, and their own copy. It holds nothing about the visitor, so everyone in the same test groups shares one copy. Only a price test enters a key, including the price change of a [combined test](/experiments/combined-test): shipping, offer, and checkout tests apply in checkout from the cart attributes. A price test changes the home page, collection and search pages, and product pages; the cart and account pages are never rewritten.

What the middleware does with each kind of request:

| Request | What the middleware does | Cached where |
| - | - | - |
| A page no test changes | Passes it through | Your CDN and Next.js, once |
| A page a price test changes | Rewrites it to the page key route and marks it `private` | Next.js, one copy per page key. Your CDN passes it through. |
| A page a [URL redirect test](/headless-sdk/url-redirects) sends elsewhere | Redirects with a 307 and the SDK's cookies, before any rewrite | Nowhere |
| A direct visit to `/abconvert/...` | Redirects to the clean URL with a 308, so no one can share a test group's copy | Nowhere |
| A first visit, or any response that sets a cookie | Sets the SDK's cookies and marks the response `private` | Next.js only |
| A request on which your tests could not load | Marks the response `private`, since it cannot tell which pages a test changes | Next.js only |
| A page in a [preview session](/headless-sdk/preview) | Marks it `no-store` | Nowhere |
| A known crawler, such as Googlebot | Decides nothing: no test group, no cookies, no page key, no redirect. The crawler gets your original store, and a tested page stays `private`. | Your CDN never stores the crawler's copy |

We trade CDN hits on tested pages for the right price on every page and a preview link that always shows the test group you asked for. If your CDN can add a cookie to its cache key, you can [cache tested pages there too](#cache-tested-pages-at-your-cdn).

<Warning>
  Keep `private` on every response that has it. A `Cache-Control` rule in `next.config.js` is fine: the middleware's value wins. A proxy or load balancer that sets `Cache-Control` must skip responses that say `private`. A rule that sets `public` on every product page makes your CDN serve one test group's prices to every visitor.
</Warning>

### Supported versions

| Next.js | Supported | Why not |
| - | - | - |
| 14.2.10 and later 14.2 releases | Yes | |
| 14 before 14.2.10 | No | Replaces the middleware's `private` with its own cache header, so a CDN could serve one test group's page to everyone |
| 15.0 | No | Does not cache a page on its first request |
| 15.1 to 15.4 | Yes | |
| 15.5 | No | Caches no page that middleware rewrites |
| 16 | Yes | |

## Options

`abconvertMiddleware` and `planMiddleware` take the [`createABConvert` options](/headless-sdk/reference#options) except `request` and `pageKey`, plus these:

| Option | Default | Purpose |
| - | - | - |
| `rewritePrefix` | `'abconvert'` | The path segment tested pages are rewritten under. Pick one that none of your routes uses. If you change it, rename the `app/abconvert` folder and the paths you pass to `revalidatePath` to match. |
| `onlyOwnProductPrices` | `false` | `true` when your product pages show no price but their own product's. See [Product pages that show only their own price](#product-pages-that-show-only-their-own-price). |
| `cdnKeyedByCookie` | `false` | Keeps tested pages cacheable at your CDN. See [Cache tested pages at your CDN](#cache-tested-pages-at-your-cdn). |

## If you already have middleware

If your middleware already rewrites or redirects, for example to add a locale prefix, call `planMiddleware` instead of `abconvertMiddleware`. It returns what `abconvertMiddleware` would do, as data, and you apply it to your own response:

```ts Server (middleware) theme={null}
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
import { planMiddleware } from '@abconvert/headless/next';
import { abconvertOptions } from './lib/abconvert';

export async function middleware(request: NextRequest) {
  const plan = await planMiddleware(request, abconvertOptions);

  let response: NextResponse;
  if (plan?.redirect) {
    response = NextResponse.redirect(plan.redirect, plan.redirectStatus);
  } else {
    // The page key path when a test changes the page, else the path as requested
    const path = plan?.rewritePath ?? `${request.nextUrl.pathname}${request.nextUrl.search}`;
    // Your own rewrite: here, a locale prefix
    response = NextResponse.rewrite(new URL(`/en${path}`, request.url), {
      request: { headers: plan?.requestHeaders ?? request.headers },
    });
  }

  for (const cookie of plan?.setCookies ?? []) response.headers.append('set-cookie', cookie);
  for (const [name, value] of Object.entries(plan?.responseHeaders ?? {})) response.headers.set(name, value);
  return response;
}
```

`planMiddleware` returns `null` for a request that is not a page, such as an image. Apply every field it returns, as the code does; the [reference](/headless-sdk/reference#planmiddleware) lists them. With a locale prefix, the tested routes move under `app/[locale]/`.

## Product pages that show only their own price

By default a product page's key carries every price test, because product pages often show other products' prices too, such as related products. If your product pages show no price but their own product's, set `onlyOwnProductPrices: true` in `abconvertOptions`: a product page's key then carries only the tests that include its product, and a product in no test stays cacheable at your CDN. If a related product's price does appear, leave it off, or that product shows its original price while checkout charges the test price.

## Shipping rates and offers on a cached page

A page rendered from a page key carries its price tests in full. Every other running test is on the page too, but only as a light entry, with its changes stripped, so the cached copy holds nothing one visitor's shipping test group would show. `getShippingRates`, `getFreeShippingThreshold`, and `getOffers` therefore return nothing on such a page, on the server and in the browser.

For a free shipping bar or an offer banner on every page, read them in a route handler that creates the client from the request, and render the bar in a client component that fetches the visitor's current values on each page load. The cached page carries no threshold, and each visitor gets their own test group's. Keep the route handler out of the middleware's matcher, as `/api` already is: the client reads the visitor's cookies from the request itself and sets none.

<CodeGroup>
  ```ts app/api/abconvert/shipping/route.ts theme={null}
  import { createABConvert } from '@abconvert/headless/next';
  import { abconvertOptions } from '@/lib/abconvert';

  export async function GET() {
    const abconvert = await createABConvert(abconvertOptions);

    return Response.json(
      { threshold: abconvert.getFreeShippingThreshold({ zone: 'United States' }), offers: abconvert.getOffers() },
      // One visitor's answer: no shared cache may keep it
      { headers: { 'cache-control': 'private, no-store' } },
    );
  }
  ```

  ```tsx components/FreeShippingBar.tsx theme={null}
  'use client';
  import { useEffect, useState } from 'react';
  import type { FreeShippingThreshold, Offer } from '@abconvert/headless';
  import { useABConvert } from '@abconvert/headless/react';

  interface Visitor {
    threshold: FreeShippingThreshold | null;
    offers: Offer[];
  }

  export function FreeShippingBar({ subtotal }: { subtotal: number }) {
    const { formatPrice, trackExposure } = useABConvert();
    const [visitor, setVisitor] = useState<Visitor | null>(null);

    useEffect(() => {
      fetch('/api/abconvert/shipping')
        .then((response) => response.json())
        .then(setVisitor)
        .catch(() => {});
    }, []);

    const [offer] = visitor?.offers ?? [];
    useEffect(() => {
      // The offer is on screen
      if (offer) trackExposure(offer.experimentId);
    }, [offer?.experimentId]);

    // A visitor can be in an offer test, a shipping test, both, or neither
    if (!visitor || (!visitor.threshold && !offer)) return null;
    const remaining = visitor.threshold ? visitor.threshold.amount - subtotal : null;

    return (
      <aside>
        {offer && <span>{offer.title}</span>}
        {visitor.threshold && remaining !== null && (
          <span>{remaining > 0 ? `Add ${formatPrice(remaining, visitor.threshold.currency)} for free shipping` : 'Your order ships free'}</span>
        )}
      </aside>
    );
  }
  ```
</CodeGroup>

Render the bar inside the provider in each route group's layout, with the cart subtotal. The threshold is `null` for a visitor in no shipping test, or in a test group with no free rate, and `offers` is `[]` for a visitor in no offer test. The bar renders whichever the visitor has, and nothing when they have neither: render your usual bar in its place.

A cart drawer priced in the browser has the same limit: a page rendered from a page key hands the browser only the test prices for its own key, so the drawer shows other products at their original price. Price the drawer in the server action or route handler that returns its cart, with the client made from the request.

## Server actions

A server action from a tested page is rewritten to the page's key, so its response renders from the same route as the page. That needs the `next-action` header Next.js sends when JavaScript calls the action. A form posted before the page hydrated, or with JavaScript off, renders its response from the usual route instead, with the original prices, until the next page load.

On a page that renders per request anyway, the `x-abconvert-url` request header holds the URL the visitor opened. Reading request headers makes a page dynamic, so do not read it on a page you want cached.

## Revalidate the page key copies too

If you refresh a product page on demand, for example from Shopify's product update webhook, revalidate both routes. `revalidatePath('/products/blue-snowboard')` refreshes the usual route only; the copies a test renders live under the page key route, and they keep the old product data until their `revalidate` window ends.

```ts Server (route handler) theme={null}
import { revalidatePath } from 'next/cache';

revalidatePath(`/products/${handle}`);
// Every page key copy of every product page, on its next visit
revalidatePath('/abconvert/[pageKey]/products/[handle]', 'page');
```

Next.js cannot revalidate one product's page key copies alone, because the keys are not known in advance, so the second call covers all of them. Each copy renders again only when a visitor next opens it. A change to a test needs no revalidation: it gives a tested page a new page key, so Next.js renders it again.

## When the tests cannot be loaded

A page rendered from a page key is cached for everyone in its test groups, so it never falls back to your original store. When the tests the key names cannot be loaded, `createABConvert` with that key waits up to 10 seconds, or your `timeout` if longer, then rejects. The render fails and Next.js keeps its last good copy of the page, instead of caching your original store under a test group's key. With no copy yet, that request fails. A page with an empty key never rejects this way. See [Loading and failures](/headless-sdk/loading).

A force from `forceTestGroup` on the server lasts only for that render, because the Next.js client sets no cookies. Use a [preview link](/headless-sdk/preview) instead.

## Cache tested pages at your CDN

| Mode | Tested pages are served by | Needs |
| - | - | - |
| Default | Next.js, from its copy per page key. Your CDN passes them through. | Nothing. Launch with this. |
| `cdnKeyedByCookie` | Your CDN, one copy per combination of test groups | A CDN that can add a named cookie to its cache key, such as Google Cloud CDN, Fastly, or Akamai |

Switch to the second mode once your tests run as expected. Two settings work together, and the order matters:

1. Add the `abconvert-key` cookie and ABConvert's three query parameters, `abconvert_force`, `abconvert_preview_key`, and `abconvert_experiment_id`, to your CDN's cache key in the same change. The cookie keeps one copy per combination of test groups; the parameters keep preview and forced links out of those copies, so a [preview link](/headless-sdk/preview) is never answered from a stored copy. On Google Cloud CDN:

   ```bash theme={null}
   gcloud compute backend-services update YOUR_BACKEND_SERVICE --global \
     --cache-key-include-named-cookie=abconvert-key
   ```

   Cloud CDN keys on the full query string by default, which covers the three parameters. If your backend service leaves the query string out of its key, or keys only on a list of parameters, add these three. Do not narrow the key to only these three, or your search and filter pages share one copy.

2. Then set `cdnKeyedByCookie`:

   ```ts Server (middleware) theme={null}
   // middleware.ts
   export function middleware(request: NextRequest) {
     return abconvertMiddleware(request, { ...abconvertOptions, cdnKeyedByCookie: true });
   }
   ```

To turn it off, go in reverse: remove `cdnKeyedByCookie` first, then the cookie from the cache key.

In this mode the middleware sets `abconvert-key` to the visitor's place in each price test, including the tests that leave them out. It holds no visitor or session ID and is the same on every page, so your CDN keeps one copy per combination of test groups, not one per visitor. Three things stay `private` whatever the mode: a response that sets a cookie, so a visitor's first page is never cached; every page a URL redirect test covers; and any page when the visitor's cookie does not match their test groups. A tested page your CDN stored before you changed a test stays until its `s-maxage` ends, since a CDN hit never reaches your middleware. See [After you launch, edit, or end a test](/headless-sdk/loading#after-you-launch-edit-or-end-a-test).

Two Google Cloud CDN details:

* Cloud CDN does not store a response whose `Vary` header names a header outside the few it supports, and Next.js sends `Vary: RSC` and other router headers. Rewrite `Vary` to `Accept-Encoding` in front of Next.js, in the proxy or server that runs it, or Cloud CDN stores no page at all.
* The cache key applies to every page behind the backend service, so untested pages are also stored once per cookie value. To keep them stored once, route your product, collection, and search paths to a second backend service with the cookie in its key.

<Warning>
  Never set `cdnKeyedByCookie` while your CDN caches by path alone. The middleware then lets a tested page be cached, and your CDN keeps one copy per path: it serves one test group's page, with its prices, to every visitor, Control included.
</Warning>


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