Статична сторінка, яка насправді 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-для-ботів — а коли завершив, прибери експеримент.
Помітили помилку?
Невірний факт, кривий переклад, щось звучить неправдиво в цій статті? Напишіть мені — своєю мовою.