David Lewin David Lewin
E-Commerce, Research & Ads

A/B testing a headless Next.js store: proxy routing vs static generation

Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.

Short answer

You don't have to choose between them. Prerender every variation as its own static page (app/pdp/[variant]/[handle], with generateStaticParams and revalidate), and let Next.js's proxy (called middleware before Next.js 16) pick the variation per visitor and rewrite /products/mug to /pdp/b/mug. The page stays static, there's no client-side swap and no flicker. Ask VWO's SDK once per visitor and keep the answer in a cookie: getFlag() sends an impression every time it's called, and the proxy can't tell a prefetch from a page view. Create one VWO client per server and reuse it: init() downloads the settings file, which cost 158 ms per request in our test against 0.4 ms for a shared client. Two production traps we hit: the rewritten response still says s-maxage=3600 under the public URL, so a URL-keyed CDN would serve one variant to everyone, and Next.js runs the proxy again for the rewritten path if the matcher covers it. VWO (now part of Wingify) is our pick.

A headless store built with Next.js and Shopify is fast because its product pages are generated ahead of time and served from a cache. An A/B test seems to fight that: if the page is the same file for everyone, how does half your visitors see a different layout? Most teams end up either swapping content in the browser, which flickers, or rendering every product page per request, which throws away the static speed. This guide does neither. Both variations are built as static pages, and Next.js's proxy decides which one each visitor gets, before rendering, with VWO making the decision.

How this guide was made

We read the Next.js 16 documentation on Proxy, VWO's Feature Management and Experimentation (FME) documentation, including its edge guide, the source of vwo-fme-node-sdk 1.80.0 and its end-to-end test settings, and Wingify's pricing page (October 2026). The code ran as a real Next.js 16.4.2 production build (next build, then next start) against a local mock of Shopify's Storefront API and of VWO's settings and event endpoints, reached through the SDK's gatewayService option. All 26 checks pass. We didn't deploy to Vercel or another host, and didn't use a live VWO account.

1. The short answer

QuestionAnswer
Static pages or proxy routing?Both: prerender each variation, and let the proxy rewrite each visitor to theirs
Where's "middleware" in Next.js 16?Renamed to Proxy (proxy.ts), Node.js runtime by default
How often to ask VWOOnce per visitor; keep the variation in a cookie. Every getFlag() call sends an impression
VWO clientOne per server, refreshed every 5 minutes, with a 1-second cap on the settings download
SEOOne public URL; the variant pages point their canonical at it. No noindex on them
CDN in front of next startDon't let it cache tested pages by URL: the rewritten page says s-maxage=3600
PurchasesPut the VWO user ID on the Shopify cart; send the event from the orders/create webhook

2. Three ways to test a static storefront

ApproachWhat happensCost
Swap in the browserThe static page loads, then a script or client component changes itFlicker or a hidden page, and React hydration mismatches if the server HTML and the client disagree
Render per requestThe page reads the cookie with cookies() and renders the variationThe route becomes dynamic. In our build, /dyn/[handle] was marked ƒ (Dynamic): no prerendering, a render on every view
Static variants + proxy rewriteEach variation is prerendered; the proxy rewrites the public URL to the visitor's variationOne page per variation per product at build time. In our build, all four (/pdp/a/mug, /pdp/b/mug, /pdp/a/tee, /pdp/b/tee) were marked ● (SSG)

Locally, the rewritten static page and the dynamic page both answered in about 35 to 40 ms, because the product data was cached either way. The difference shows under load and at a CDN: a static variation can be served from a cache, a dynamic page renders every time.

3. Middleware is now Proxy, and where "the edge" is

In Next.js 16 the middleware file convention is deprecated and renamed to proxy, and Proxy defaults to the Node.js runtime; the runtime option isn't available in a proxy file. It still runs before any route is rendered, and it can rewrite, redirect and set cookies. Whether that happens at a CDN edge or on your server depends on where you deploy. With next start, it's your Node process. The design here works either way, because the proxy never renders anything, it only picks a URL.

Two details from the docs matter for testing. Proxy can't see Next's internal RSC headers (rsc, next-router-prefetch), so it can't tell a client-side navigation or a prefetch from a page view. And NextResponse.rewrite() carries those requests through to the right variation.

A request for /products/mug
A request for /products/mugVisitor → proxy.ts, first visit / proxy.ts, cookie present → Rewrite → ResponseVisitor: GET /products/mug, with or without the ab_pdp_layout cookieVisitorGET /products/mug, with or without the ab_pdp_layout cookieproxy.ts, first visit: VWO getFlag() from settings already in memory, one impressionproxy.ts, first visitVWO getFlag() from settingsalready in memory, oneimpressionproxy.ts, cookie present: reuse the cookie: no VWO call, no impressionproxy.ts, cookie presentreuse the cookie: no VWOcall, no impressionRewrite: /pdp/b/mug, prerendered at build time, revalidated hourlyRewrite/pdp/b/mug, prerendered at build time, revalidated hourlyResponse: the variation's HTML plus Set-Cookie; the impression is flushed after the responseResponsethe variation's HTML plus Set-Cookie; the impression is flushedafter the response
The proxy picks a URL and never renders. Both variations are ordinary static pages.

4. One VWO client per server

VWO's SDK starts with init(), which downloads your project's settings file and sends an SDK-init event. After that, getFlag() is a local calculation: a hash of the user ID against the settings. Calling init() per request puts a VWO round trip in front of every page view. VWO's edge guide warns about this and suggests keeping settings in KV or Redis on platforms that don't keep instances warm.

import {init} from 'vwo-fme-node-sdk'

export const FLAG = 'pdp_layout'      // VWO feature flag with a testing rule; its string variable "layout" names the page variant
export const VARIANTS = ['a', 'b']   // must match the "layout" values in VWO and the static pages under app/pdp/[variant]
export const COOKIE = 'ab_pdp_layout' // rename it for the next test, or visitors keep this test's assignment

const timeout = (ms: number) => new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`VWO: no settings in ${ms} ms`)), ms))

type Client = Awaited<ReturnType<typeof init>>
let current: Client | null = null
let loading: Promise<Client> | null = null
let loadedAt = 0

// One client per server instance, refreshed every 5 minutes. init() downloads the settings file and sends an init
// event, so calling it per request would put a VWO round trip in front of every page view.
export function vwo(): Promise<Client> {
  if (current && Date.now() - loadedAt < 300_000) return Promise.resolve(current)
  loading ??= Promise.race([init({
    accountId: process.env.VWO_ACCOUNT_ID!,
    sdkKey: process.env.VWO_SDK_KEY!,
    logger: {level: 'ERROR'},
    ...(process.env.VWO_GATEWAY_URL && {gatewayService: {url: process.env.VWO_GATEWAY_URL}}),
  }), timeout(1000)]) // a hung settings download must not hold the next request too
    .then((c) => { [current, loadedAt] = [c, Date.now()]; return c })
    .finally(() => { loading = null })
  return current ? Promise.resolve(current) : loading // old settings keep answering while new ones load
}
Time to a getFlag() decision (median of 10)
Time to a getFlag() decision (median of 10)init() on every request, settings served in 150 ms: 158 ms; One shared client: 0.44 msinit() on every request, settings served in 150 ms: 158 msinit() on every request, settings served in 150 ms158 msOne shared client: 0.44 msOne shared client0.44 ms
Measured with the SDK against our mock, which delayed the settings file by 150 ms. Your settings download time depends on where your server runs.

The 1-second cap matters because of how it fails. With the settings endpoint hanging, the first request waited about 1 second and got the default variation, and so did the next one, instead of queueing behind a download that never finished. When the endpoint came back, the next request loaded the settings and bucketed the visitor.

5. proxy.ts

import {NextResponse, type NextFetchEvent, type NextRequest} from 'next/server'
import {COOKIE, FLAG, VARIANTS, vwo} from '@/lib/vwo'

const cookie = {httpOnly: true, secure: true, sameSite: 'lax' as const, path: '/'}

export async function proxy(req: NextRequest, event: NextFetchEvent) {
  const {pathname} = req.nextUrl

  const uid = req.cookies.get('vwo_uid')?.value ?? crypto.randomUUID()
  let variant = req.cookies.get(COOKIE)?.value
  const known = VARIANTS.includes(variant ?? '')
  if (!known) { // ask VWO once per visitor; later pages, prefetches and RSC requests reuse the cookie and send no impression
    try {
      const client = await vwo()
      const flag = await client.getFlag(FLAG, {id: uid, userAgent: req.headers.get('user-agent') ?? ''})
      variant = flag.isEnabled() ? flag.getVariable('layout', VARIANTS[0]) : undefined
      event.waitUntil(client.flushEvents()) // the SDK batches events by default: send this impression after the response
    } catch (e) {
      console.error('VWO unavailable, serving the default variant:', (e as Error).message)
    }
  }
  const inTest = VARIANTS.includes(variant ?? '')

  const url = req.nextUrl.clone()
  url.pathname = `/pdp/${inTest ? variant : VARIANTS[0]}${pathname.slice('/products'.length)}`
  const res = NextResponse.rewrite(url)
  if (!req.cookies.has('vwo_uid')) res.cookies.set('vwo_uid', uid, {...cookie, maxAge: 365 * 24 * 3600})
  if (inTest && !known) res.cookies.set(COOKIE, variant!, {...cookie, maxAge: 90 * 24 * 3600}) // only real decisions stick
  return res
}

// Only the public URL. Next runs the proxy again for the rewritten /pdp/... request when the matcher covers it,
// so a redirect for /pdp/ here would bounce every visitor; the variant pages point their canonical at /products/ instead.
export const config = {matcher: '/products/:handle'}
  • VWO is asked once per visitor. getFlag() sends a vwo_variationShown impression every time it runs. (SDK 1.80's batch getFlags() takes a third argument that suppresses tracking, but then deciding when to send the impression is up to you.) Since the proxy can't tell prefetches and client navigations from page views, asking on every request would send an impression for each of them. With the cookie, one visitor's 13 page views of two products, plus an RSC navigation and a prefetch, produced exactly one impression.
  • Only real decisions are stored. If VWO is unreachable or the visitor isn't in the test's traffic, the default page is served and no layout cookie is set, so an outage doesn't lock anyone into the default for 90 days.
  • The impression is sent after the response. The SDK batches events by default; flushEvents() in waitUntil sends them without making the visitor wait, and keeps them from being lost when a serverless instance is frozen. In our test the mock was reached through gatewayService, which turns batching off, so events went out one by one.
  • The matcher is only the public URL. We first matched /pdp/:path* too, to redirect anyone who found an internal URL. Every visitor got a 301 loop: under next start, the proxy ran a second time for the rewritten /pdp/a/mug request and redirected it. Leave the internal paths out of the matcher.

6. The static variant pages

import {notFound} from 'next/navigation'
import {getHandles, getProduct} from '@/lib/shopify'
import {ProductView} from '@/lib/product-view'
import {VARIANTS} from '@/lib/vwo'

// Every variant of every product is a static page, built ahead and revalidated hourly. proxy.ts only picks which one.
export const revalidate = 3600

export async function generateStaticParams() {
  const handles = await getHandles()
  return VARIANTS.flatMap((variant) => handles.map((handle) => ({variant, handle})))
}

export async function generateMetadata({params}: {params: Promise<{handle: string}>}) {
  const {handle} = await params
  return {alternates: {canonical: `/products/${handle}`}} // one URL for search engines, whatever the variant
}

export default async function Page({params}: {params: Promise<{variant: string; handle: string}>}) {
  const {variant, handle} = await params
  const product = await getProduct(handle)
  if (!product || !VARIANTS.includes(variant)) notFound()
  return <ProductView product={product} layout={variant} />
}

The page never reads cookies or headers, which is what keeps it static. The ProductView component takes the layout as a prop and renders on the server, so both variations are complete HTML at build time. The canonical link points at /products/{handle} on both variations. Don't add noindex to the variant route: it's the same HTML that's served at the public URL, so noindex would apply to your real product page too. A direct visit to /pdp/b/mug renders with the canonical pointing home, and nothing links to it.

7. The caching trap

The rewritten /products/mug response carried Cache-Control: s-maxage=3600, stale-while-revalidate=31532400, the variant page's ISR headers, under the public URL. A cache that keys on the URL, such as a "cache everything" rule at Cloudflare or a CloudFront distribution in front of next start, would store whichever variation it saw first and serve it to everyone, cookies or not.

  • If you self-host behind a CDN, exclude tested paths from HTML caching at the CDN, or move the split to the CDN itself. Our WooCommerce guide does that with a Cloudflare Worker that puts the variation in the cache key.
  • On a managed Next.js host, check how its cache treats rewrites from Proxy before launching. We didn't test any.

8. Counting purchases from Shopify

The checkout happens on Shopify, not in your Next.js app, so the purchase event has to come from Shopify. Put the visitor's VWO user ID on the cart as an attribute when you create it. Cart attributes become the order's note attributes:

mutation cartCreate($input: CartInput!) {
  cartCreate(input: $input) { cart { id checkoutUrl } }
}
# variables: { "input": { "lines": [...], "attributes": [{ "key": "_vwo_uid", "value": "<vwo_uid cookie>" }] } }

Then a route handler for the orders/create webhook verifies Shopify's HMAC, reads the attribute and sends VWO a purchase event with the order total:

import {createHmac, timingSafeEqual} from 'node:crypto'
import {after} from 'next/server'
import {vwo} from '@/lib/vwo'

const seen = new Set<string>() // ponytail: per-instance dedupe of Shopify retries; use Redis or a DB row if you run several instances

// Shopify orders/create webhook -> VWO "purchase" event for the visitor ID the storefront put on the cart.
export async function POST(req: Request) {
  const raw = await req.text()
  const sig = Buffer.from(req.headers.get('x-shopify-hmac-sha256') ?? '', 'base64')
  const want = createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET!).update(raw).digest()
  if (sig.length !== want.length || !timingSafeEqual(sig, want)) return new Response('bad signature', {status: 401})

  const eventId = req.headers.get('x-shopify-event-id') ?? ''
  const order = JSON.parse(raw)
  const uid = order.note_attributes?.find((a: {name: string}) => a.name === '_vwo_uid')?.value
  if (uid && !seen.has(eventId)) {
    seen.add(eventId)
    const client = await vwo()
    await client.trackEvent('purchase', {id: uid}, {amount: Number(order.current_total_price)})
    after(() => client.flushEvents())
  }
  return new Response(null, {status: 200})
}

Shopify retries webhooks, so the handler skips an event ID it has already processed; with more than one server instance, keep those IDs in Redis or a database instead of memory. We tested the handler, not the cartCreate call.

9. Test results

CheckResult
Build: both variations of both products prerendered (●); the cookie-reading comparison route dynamic (ƒ); proxy compiledPass
VWO settings hanging at startup: default page in about 1 s, no layout cookie; the next request also capped at about 1 s; after recovery the visitor is assignedPass
New visitor: one layout in the HTML; vwo_uid and ab_pdp_layout cookies, HttpOnly and SecurePass
Rewritten page served from the prerender cachePass
Rewritten page's Cache-Control is s-maxage=3600, stale-while-revalidate=31532400Pass (documents the trap)
Canonical is /products/mug on the variationPass
13 page views of 2 products, an RSC navigation and a prefetch: same layout, one impression in totalPass
200 new visitors: 107 / 93; settings downloaded once; 201 visitors, 201 impressionsPass
Cookie-reading dynamic route renders the same layoutPass
/pdp/b/mug directly: renders with the canonical at /products/mug, no noindexPass
Webhook: bad signature 401; valid one 200 and a purchase event with the amount; retry with the same event ID: no second eventPass
init() per request vs a shared client: 158 ms vs 0.44 msPass

Not tested: Vercel or another managed host, the Edge runtime, a live VWO account and its reports, VWO's batching path (our mock route disables it), and the cartCreate mutation.

10. Our opinion: trade-offs

  • Static variants are the right default for layout and content tests on product and collection pages. You keep the build-time speed and the test can't flicker.
  • The cost is build size: two variations double the prerendered pages for the tested route. Test on the routes you mean to, and prerender only your top products if the catalog is large; the rest generate on first request with revalidate.
  • Per-user personalization doesn't fit this model. If the page depends on who the visitor is, not which of two layouts they got, render it dynamically or stream that part.
  • Keep the experiment logic in the proxy small. It runs before every matched request; a settings download or an extra network call there is felt on every page.
  • Rename the cookie for each new test. Otherwise visitors carry the last test's assignment into the next one.

Related: ISR and on-demand revalidation for a headless Shopify store and adding reviews to headless Shopify without hurting LCP.

11. Tools

Top pick

ToolRoleWhy it fits
VWOFeature flags and experiments with a Node.js SDKVWO's Feature Management and Experimentation SDK decides locally from a downloaded settings file, so a proxy can bucket a visitor in well under a millisecond once the client is warm. A flag's variables (here, layout) map cleanly to prerendered variants, purchase events go through the same SDK, and VWO documents edge setups. VWO and AB Tasty now operate under the Wingify brand; VWO says plans and products are unchanged. Pricing is by monthly active users and isn't fully published; get a quote for FME (October 2026).

Alternatives: free tools and references

We don't earn anything from these links.

  • Flags SDK: Vercel's open-source library for feature flags in Next.js, with a "precompute" pattern for static variants.
  • GrowthBook: open-source experimentation you can host yourself.
  • Next.js: proxy.js reference: runtime, matchers, cookies and the RSC header note quoted here.

12. Pitfalls

  • Reading cookies in the product page to choose the variation: the route stops being static.
  • Swapping layouts in a client component: flicker, and hydration warnings when server and client disagree.
  • Calling init() in the proxy on every request: a settings download per page view.
  • Calling getFlag() on every request: an impression for every prefetch and navigation.
  • No timeout on the settings download: a VWO outage becomes slow pages for everyone.
  • Matching the internal variant paths in the proxy: it runs again on the rewrite.
  • A URL-keyed CDN caching the public URL: one variation for everyone.
  • noindex on the variant route: it lands on your real product page.
  • No user ID on the cart: purchases can't be tied back to a variation.
Bottom line

Build every variation as a static page, and let the proxy choose the URL. Ask VWO once per visitor, keep the answer in a cookie, share one VWO client per server and cap its settings download. Keep CDNs from caching tested HTML by URL, keep the canonical on the public URL, and send purchases from Shopify's webhook. The store stays static, and the test doesn't flicker.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.