Статичная страница, которая на самом деле A/B-тест
A/B-тест страницы обычно реализуют так, что она слетает с CDN на рендер-на-каждый-запрос. Но так быть не должно. Держи все варианты полностью статичными, а решение пусть принимает proxy (переименованный middleware в Next.js) — через платформу для A/B-экспериментов, по стабильному device id, и делает rewrite только когда посетитель не в control. URL не меняется, CDN по-прежнему кеширует, а горячий путь не платит ничего.
Каждый раз, когда ты 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 — обычная статичная страница. Варианты живут под роутом, на который ничто не линкует напрямую:
// The control — a plain static page.
export const dynamic = "force-static";
export default function Pricing() {
return <Control />;
}// 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 (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" и кешируешь сам, в памяти:
// 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 на роут, который перестраивает карту.
// 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();
}// 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-для-ботов — а когда закончил, убери эксперимент.
Заметили ошибку?
Неверный факт, кривой перевод, что-то звучит неправдой в этой статье? Напишите мне — на своём языке.