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-для-ботов — а когда закончил, убери эксперимент.

Заметили ошибку?

Неверный факт, кривой перевод, что-то звучит неправдой в этой статье? Напишите мне — на своём языке.