Skip to main content
Назад до блогу
Next.jsA/B TestingProxyPerformanceSSG

Статична сторінка, яка насправді A/B-тест

A/B-тест сторінки зазвичай реалізують так, що вона злітає з CDN на рендер-на-кожен-запит. Але не обов'язково. Тримай усі варіанти повністю статичними, а рішення хай ухвалює proxy (перейменований middleware у Next.js) — через платформу для A/B-експериментів, за стабільним device id, і робить rewrite лише коли відвідувач не в control. URL не змінюється, CDN далі кешує, а гарячий шлях не платить нічого.

Опубліковано 14 серпня 2026 р.8 хв читання

Щоразу, коли ти A/B-тестуєш сторінку, простий шлях тихо робить її динамічною — і ти втрачаєш CDN. Цього обміну можна уникнути. Ось як тримати кожен варіант статичним і сховати весь експеримент за proxy.

У чому конфлікт

Ти хочеш A/B-тестувати сторінку — прайсинг, лендинг-hero, крок онбордингу. Щойно ти це робиш, вона зазвичай перестає бути статичною:

  • Клієнтський A/B вантажить JavaScript, спершу блимає оригінальним варіантом і зсуває верстку. Ще й узагалі не працює без JS.
  • Рендер на кожен запит працює, але кожен хіт іде на твій origin — нема CDN-кешу, гірший TTFB, більше обчислень.
  • Vary: Cookie виглядає спокусливо, але дробить кеш на окремий запис під кожне значення cookie.

Але сторінка все одно, по суті, статичний HTML. Єдине динамічне — який саме статичний HTML і чи він узагалі відрізняється від дефолтного. Ізолюй саме це.

Ідея

  • Тримай control як реальну статичну сторінку на /pricing.
  • Тримай кожен не-control варіант окремою статичною сторінкою під виділеним роутом.
  • У proxy визнач варіант відвідувача через SDK для A/B-експериментів. Control? Не роби нічого — статична сторінка рендериться як є. Варіант? Rewrite на статичну сторінку цього варіанта. URL в обох випадках той самий.

Найхитріше тут — асиметрія: більшість трафіку це control, а control не коштує жодного rewrite.

Стабільний ідентифікатор

Консистентне розподілення потребує стабільного id, а не свіжого підкидання монетки на кожен запит. Достатньо довгоживучого cookie device_id — SDK для експериментів хешує його (плюс атрибути таргетингу), тож той самий пристрій завжди потрапляє в той самий варіант, а спліт лишається на заданих тобою вагах.

Той самий id — твій ключ атрибуції: кожну конверсію можна простежити до бакета, в якому був пристрій.

Сторінки

Control — звичайна статична сторінка. Варіанти живуть під роутом, на який ніщо не лінкує напряму:

app/pricing/page.tsx
// The control — a plain static page.
export const dynamic = "force-static";

export default function Pricing() {
  return <Control />;
}
app/ab/pricing/[variant]/page.tsx
// Non-control variants, also static.
export const dynamic = "force-static";
export const dynamicParams = false; // only the variants you built

export function generateStaticParams() {
  return [{ variant: "b" }, { variant: "c" }];
}

export default async function Variant({
  params,
}: {
  params: Promise<{ variant: string }>;
}) {
  const { variant } = await params;
  return variant === "c" ? <VariantC /> : <VariantB />;
}

Control і кожен варіант пререндеряться й кешуються незалежно.

Proxy — увесь секрет

proxy.ts
// proxy.ts  (this was middleware.ts before Next.js 16)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { ensureConfigReady, getSplitConfig } from "@/lib/experiments/config-cache";
import { resolveVariant } from "@/lib/experiments"; // wraps GrowthBook, Statsig, etc.

export const config = { matcher: "/pricing" };

export async function proxy(request: NextRequest) {
  // 1. Config first — a synchronous, in-memory lookup. No test here? Do nothing.
  await ensureConfigReady(); // no-op once warm
  const split = getSplitConfig("pricing");
  if (!split) return NextResponse.next();

  // 2. A stable id for consistent bucketing — reuse it, or mint one on first visit.
  let deviceId = request.cookies.get("device_id")?.value;
  const isNewDevice = !deviceId;
  if (!deviceId) deviceId = crypto.randomUUID();

  // 3. Ask the experimentation platform which variant this device is in.
  //    The SDK does the consistent hashing — no Math.random() here.
  const variant = await resolveVariant(split, {
    device_id: deviceId,
    country: request.headers.get("cf-ipcountry") ?? undefined,
    // locale, utm_source, … — whatever you target on
  });

  // 4. Control is the real page: render as-is. Only variants rewrite.
  const res =
    variant === "control"
      ? NextResponse.next()
      : NextResponse.rewrite(new URL(`/ab/pricing/${variant}`, request.url));

  // 5. Persist the id; drop a short-lived breadcrumb for analytics.
  if (isNewDevice) {
    res.cookies.set("device_id", deviceId, {
      path: "/",
      sameSite: "lax",
      maxAge: 60 * 60 * 24 * 365,
    });
  }
  res.cookies.set("ab_pricing", variant, {
    path: "/",
    sameSite: "lax",
    maxAge: 60 * 60, // an attribution breadcrumb, not the source of truth
  });
  return res;
}

У старішому проєкті це middleware.ts з export function middleware — логіка та сама (proxy-доки). Next.js має codemod для перейменування: npx @next/codemod middleware-to-proxy .

Config, на якому працює proxy — кешований у памʼяті

Proxy не може магічно знати, які сторінки під тестом. Ця мапа — яка сторінка має експеримент, її ключ, slug кожного варіанта — лежить у твоїй CMS. Але фетчити її на кожен запит не можна (це ставить CMS у гарячий шлях кожного завантаження), а рантайм проксі не дає тобі fetch-кешу, який ти б використав у Server Component. Тож ти вимикаєш його через cache: "no-store" і кешуєш сам, у памʼяті:

lib/experiments/config-cache.ts
// Module scope, in-memory, no fetch cache.
type SplitConfig = { key: string; variants: { value: string; slug: string }[] };

const configByPage = new Map<string, SplitConfig>();
let ready = false;
let inflight: Promise<void> | null = null;

async function refresh() {
  // The proxy runtime has no usable fetch cache — opt out and cache ourselves.
  const res = await fetch(`${process.env.CMS_URL}/experiments`, { cache: "no-store" });
  const next = toConfigMap(await res.json());
  configByPage.clear(); // atomic-ish swap
  for (const [page, cfg] of next) configByPage.set(page, cfg);
  ready = true;
}

/** Rebuild — call this from a CMS webhook when experiment content changes. */
export async function refreshExperimentConfig() {
  try {
    await refresh();
  } catch {
    ready = true; // on failure, keep the old map and carry on
  }
}

/** Lazy single-flight init: the first caller fetches, the rest await it. */
export async function ensureConfigReady() {
  if (ready) return;
  if (!inflight) inflight = refreshExperimentConfig().finally(() => (inflight = null));
  await inflight;
}

/** Synchronous read — this is what the proxy calls on the hot path. */
export function getSplitConfig(page: string) {
  return configByPage.get(page);
}

Дві речі тримають його свіжим без мережевого хіта на гарячому шляху. Прогрій його раз на старті в instrumentation.ts — його register() виконується один раз і мусить завершитися до того, як сервер почне приймати запити, тож перший відвідувач ніколи не платить за fetch. А оновлюй його, коли міняється контент, а не за таймером: наведи вебхук CMS на роут, який перебудовує мапу.

instrumentation.ts
// register() runs once at boot and must finish before the server serves.
export async function register() {
  const { refreshExperimentConfig } = await import("@/lib/experiments/config-cache");
  await refreshExperimentConfig();
}
app/api/experiments/refresh/route.ts
// Your CMS calls this whenever experiment content changes.
import { refreshExperimentConfig } from "@/lib/experiments/config-cache";

export async function POST() {
  await refreshExperimentConfig();
  return new Response("ok");
}

Один нюанс, що болить на масштабі: цей кеш живе на кожному інстансі окремо. Один вебхук оновлює лише той контейнер, що його прийняв — усі інші контейнери й регіони досі тримають свою застарілу копію. У мультирегіональному деплої інвалідацію треба розіслати на кожен інстанс, щоб кожен proxy перебудувався — розсилка на всі регіональні деплойменти або pub/sub, на який інстанси підписані — плюс purge CDN для зачеплених URL. Цей fan-out — задача інфри, не коду застосунку. Немає такої обвʼязки? Короткий TTL на кеші обмежить застарілість натомість — простіше, просто не миттєво.

Чому платформа для A/B-експериментів, а не Math.random()

Власний random у proxy технічно ділить трафік, але ти втрачаєш усе, що робить експеримент надійним. Платформа на кшталт GrowthBook (або Statsig, LaunchDarkly, Unleash) дає тобі:

  • Консистентний хешинг за device_id, тож відвідувач ніколи не стрибає між варіантами.
  • Ваги й поступовий rollout, які змінюєш без деплою — 50/50, 5% canary, розгін до 100%.
  • Таргетинг за атрибутами, які ти передаєш (країна, локаль, UTM, план), обчислений на сервері.
  • Kill switch і одне місце, де видно всі активні експерименти.

Proxy лише питає в платформи варіант і робить rewrite. Логіка експериментів живе там, де їй місце.

Чому кеш і далі працює

CDN кешує за ціллю rewrite. Control — це /pricing (кешується). Кожен варіант — /ab/pricing/b, /ab/pricing/c (кешуються). Відвідувач весь час бачить /pricing. Жодного Vary: Cookie, жодного дроблення per-user — а control, шлях більшості, узагалі не проходить через rewrite.

Аналітика

Короткоживучий cookie ab_pricing несе визначений бакет. Читай його там, де відправляєш події — на сервері із запиту або на клієнті, щоб тегати аналітику — тож кожна конверсія атрибутується до control, b чи c. І сторінка при цьому ніколи не залишає CDN.

Не дай цьому просочитись у SEO

  • Постав canonical на /pricing на сторінках варіантів, щоб пошук консолідувався на реальному URL.
  • Тримай /ab/* поза sitemap і додай йому noindex.
  • Хай SDK повертає control для краулерів (або коли немає device_id) — боти отримують одну стабільну сторінку.

Пастки, що коштують вечора

  • Control не робить нічого. Гілка control — це NextResponse.next(): жодного rewrite, жодної сторінки варіанта, нуль вартості на гарячому шляху. Ця асиметрія — і є суть.
  • Зроби її статичною. Познач control і сторінки варіантів як force-static, щоб вони пререндерились і кешувались; випадковий динамічний API опустить їх до рендеру на кожен запит.
  • Proxy тепер Node-first (v16). Тримай його худим — читання cookie, один виклик SDK, rewrite — щоб він лишався дешевим, де б не деплоївся.
  • Таргетинг береться із запиту. Гео з CDN-хедера (cf-ipcountry), локаль зі шляху, UTM із query — збери атрибути в proxy і передай їх у SDK.
  • Плануй вихід. Коли варіант перемагає, вклей його в /pricing, потім видали роут варіанта й сам експеримент. A/B-тест, залишений назавжди, — це техборг з cookie.

Висновки

  • Сторінка, яка є A/B-тестом, все одно може бути на 100% статичною. Динамічне лише те, яка саме статична сторінка — і зазвичай це control, з яким узагалі нічого не треба робити.
  • Визначай варіант у proxy через платформу для A/B-експериментів за стабільним device id — а не random на кожен запит.
  • Rewrite лише для не-control варіантів; CDN кешує control і кожен варіант як окрему сторінку. Жодного Vary: Cookie, жодного хіта в origin.
  • Cookie з device-id дає sticky-бакетинг і атрибуцію; короткий cookie-хлібна-крихта доносить варіант до аналітики.
  • Захисти SEO через canonical, noindex на роуті варіантів і control-для-ботів — а коли завершив, прибери експеримент.

Помітили помилку?

Невірний факт, кривий переклад, щось звучить неправдиво в цій статті? Напишіть мені — своєю мовою.