Шын мәнінде A/B-тест болып шыққан статикалық бет
A/B-тестті әдетте бетті CDN-нен түсіріп, әр сұранысқа рендерге түсетіндей етіп жасайды. Бірақ бұлай болуы міндетті емес. Әр нұсқаны толық статикалық ұста, ал шешімді proxy (Next.js-те қайта аталған middleware) қабылдасын — эксперимент платформасы арқылы, тұрақты 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 арқылы шеш. Control? Ештеңе жасама — статикалық бет сол қалпында рендерленеді. Нұсқа ма? Сол нұсқаның статикалық бетіне rewrite жаса. URL екі жағдайда да сол күйінде.
Ең қулық тұс — асимметрия: трафиктің көбі control, ал control бірде-бір rewrite тұрмайды.
Тұрақты идентификатор
Консистентті бөлу тұрақты id-ді талап етеді, әр сұранысқа жаңа тиын лақтыруды емес. Ұзақ жасайтын device_id cookie жеткілікті — эксперимент-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 .
Proxy жұмыс істейтін config — жадта кэштелген
Proxy қай беттер тест астында екенін сиқырмен біле алмайды. Ол карта — қай бетте эксперимент бар, оның кілті, әр нұсқаның slug-ы — сенің CMS-іңде жатыр. Бірақ оны әр сұранысқа фетчтеуге болмайды (бұл CMS-ті әр беттеу жүктемесінің ыстық жолына қояды), ал proxy рантаймы саған Server Component-те қолданатын fetch-кэшін бермейді. Сондықтан оны 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 қайта құрылады — барлық регионалды деплойларға broadcast немесе инстанстар жазылатын pub/sub — қоса зардап шеккен URL-дерге CDN purge. Бұл fan-out — инфра ісі, қосымша коды емес. Ондай сымдама жоқ па? Оның орнына кэштегі қысқа TTL ескіруді шектейді — қарапайымырақ, тек лезде емес.
Неге эксперимент-SDK, ал Math.random() емес
Proxy-де өз random-ың техникалық тұрғыда трафикті бөледі, бірақ экспериментті сенімді ететін бәрінен айырыласың. 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 жоқ, әр қолданушыға бөлшектеу жоқ — ал control, көпшілік жол, тіпті rewrite арқылы өтпейді.
Аналитика
Қысқа өмірлі ab_pricing cookie шешілген бакетті алып жүреді. Оны оқиға жіберетін жеріңде оқы — серверде сұраныстан немесе клиентте аналитиканы тегтеу үшін — сонда әр конверсия control, b не c-ға атрибутталады. Әрі бет ол үшін CDN-нен ешқашан кетпейді.
Оны SEO-ға сыздырма
- Нұсқа беттерінде
/pricing-ке canonical қой, сонда іздеу нақты 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-де тұрақты device id бойынша эксперимент платформасы арқылы шеш — әр сұранысқа random емес.
- Rewrite тек control емес нұсқалар үшін; CDN control мен әр нұсқаны жеке бет ретінде кэштейді.
Vary: Cookieжоқ, origin-ге хит жоқ. - Device-id cookie тұрақты бөлу мен атрибуция береді; қысқа cookie-нан-үгінді нұсқаны аналитикаға жеткізеді.
- SEO-ны canonical-мен, нұсқа роутындағы
noindex-пен және боттарға-control-мен қорға — ал аяқтаған соң экспериментті бөлшекте.
Қате байқадыңыз ба?
Осы мақалада қате факт, олқы аударма немесе жалған естілетін нәрсе бар ма? Маған жазыңыз — өз тіліңізде.