O pagină statică care e, în secret, un test A/B
Un test A/B e de obicei construit așa încât pagina cade de pe CDN într-un render la fiecare cerere. Nu trebuie să fie așa. Ține fiecare variantă complet statică și lasă un proxy (middleware-ul redenumit din Next.js) să rezolve alocarea printr-o platformă de experimente, pe baza unui device id stabil — și rescrie doar când vizitatorul nu e în control. URL-ul nu se schimbă niciodată, CDN-ul tot pune în cache, iar calea obișnuită nu costă nimic.
De fiecare dată când faci un test A/B pe o pagină, calea ușoară o face dinamică pe tăcute — și pierzi CDN-ul. Nu ești obligat să faci acest compromis. Iată cum ții fiecare variantă statică și ascunzi tot experimentul în spatele unui proxy.
Tensiunea
Vrei să testezi A/B o pagină — prețuri, un hero de landing, un pas de onboarding. În clipa în care o faci, de obicei încetează să mai fie statică:
- A/B pe client livrează JavaScript, arată întâi varianta originală și mută layout-ul. Și are nevoie de JS ca să funcționeze deloc.
- Render la fiecare cerere merge, dar acum fiecare hit ajunge la origin-ul tău — fără cache CDN, TTFB mai prost, mai mult calcul.
Vary: Cookiepare tentant, dar fragmentează cache-ul într-o intrare per valoare de cookie.
Dar pagina rămâne, în esență, HTML static. Singurul lucru dinamic e care HTML static — și dacă diferă măcar de cel implicit. Izolează exact asta.
Ideea
- Ține controlul ca pagina reală, statică la
/pricing. - Ține fiecare variantă non-control ca propria pagină statică sub o rută dedicată.
- Într-un proxy, rezolvă varianta vizitatorului printr-un SDK de experimente. Control? Nu face nimic — pagina statică se randează așa cum e. O variantă? Rescrie către pagina ei statică. URL-ul e același în ambele cazuri.
Partea isteață e asimetria: cea mai mare parte a traficului e control, iar controlul nu costă niciun rewrite.
Identificatorul stabil
Bucketing-ul consistent are nevoie de un id stabil, nu de o nouă aruncare de monedă la fiecare cerere. Un cookie device_id de lungă durată e suficient — SDK-ul de experimente îl hashuiește (plus eventualele atribute de targetare), astfel că același dispozitiv nimerește mereu în aceeași variantă, iar împărțirea rămâne la ponderile pe care le-ai setat.
Același id e cheia ta de atribuire: fiecare conversie poate fi urmărită înapoi la bucket-ul în care era dispozitivul.
Paginile
Controlul e o pagină statică obișnuită. Variantele stau sub o rută către care nimic nu leagă direct:
// 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 />;
}Controlul și fiecare variantă sunt prerandate și puse în cache independent.
Proxy-ul — tot secretul
// 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;
}Într-un proiect mai vechi, asta e middleware.ts cu export function middleware — aceeași logică (docs proxy). Next.js oferă un codemod ca să-l redenumești: npx @next/codemod middleware-to-proxy .
Config-ul pe care rulează proxy-ul — pus în cache în memorie
Proxy-ul nu poate ști prin magie ce pagini sunt sub test. Harta aceea — ce pagină are un experiment, cheia ei, slug-ul fiecărei variante — stă în CMS-ul tău. Dar nu poți să o aduci la fiecare cerere (asta pune CMS-ul în calea fierbinte a fiecărei încărcări), iar runtime-ul proxy-ului nu-ți dă cache-ul de fetch pe care l-ai folosi într-un Server Component. Așa că renunți la el cu cache: "no-store" și îl pui tu în cache, în memorie:
// 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);
}Două lucruri îl țin proaspăt fără un hit de rețea pe calea fierbinte. Încălzește-l o dată la pornire în instrumentation.ts — register()-ul lui rulează o dată și trebuie să se termine înainte ca serverul să accepte cereri, așa că primul vizitator nu plătește niciodată pentru fetch. Și reîmprospătează-l când se schimbă conținutul, nu pe un timer: îndreaptă un webhook de la CMS către o rută care reconstruiește harta.
// 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");
}O rezervă care mușcă la scară: acest cache trăiește per instanță. Un singur webhook reîmprospătează doar containerul care l-a primit — fiecare alt container și regiune își ține propria copie învechită. Într-un deployment multi-regiune trebuie să împrăștii invalidarea către fiecare instanță ca fiecare proxy să se reconstruiască — un broadcast către toate deployment-urile regionale, sau un pub/sub la care se abonează instanțele — plus un purge de CDN pentru URL-urile afectate. Acel fan-out ține de infra, nu de codul aplicației. N-ai instalația asta? Un TTL scurt pe cache mărginește învechirea în schimb — mai simplu, doar că nu instant.
De ce un SDK de experimente, nu Math.random()
Un random propriu în proxy împarte tehnic traficul, dar pierzi tot ce face un experiment demn de încredere. O platformă precum GrowthBook (sau Statsig, LaunchDarkly, Unleash) îți dă:
- Hashing consistent după
device_id, ca un vizitator să nu sară niciodată între variante. - Ponderi și rollout gradual pe care le schimbi fără deploy — 50/50, un canary de 5%, urcare la 100%.
- Targetare pe atributele pe care le trimiți (țară, limbă, UTM, plan), evaluată pe server.
- Un kill switch și un singur loc unde vezi fiecare experiment activ.
Proxy-ul doar cere platformei o variantă și rescrie. Logica de experimentare stă acolo unde îi e locul.
De ce cache-ul tot funcționează
CDN-ul cheiește pe ținta rewrite-ului. Controlul e /pricing (în cache). Fiecare variantă e /ab/pricing/b, /ab/pricing/c (în cache). Vizitatorul vede /pricing tot timpul. Fără Vary: Cookie, fără fragmentare per utilizator — iar controlul, calea majoritară, nici măcar nu e rescris.
Analiză
Cookie-ul de scurtă durată ab_pricing poartă bucket-ul rezolvat. Citește-l oriunde emiți evenimente — pe server din cerere, sau pe client ca să-ți etichetezi analiza — astfel încât fiecare conversie e atribuită la control, b sau c. Iar pagina nu părăsește niciodată CDN-ul pentru asta.
Nu-l lăsa să se scurgă în SEO
- Pune un canonical spre
/pricingpe paginile de variantă, ca motorul de căutare să se consolideze pe URL-ul real. - Ține
/ab/*în afara sitemap-ului și adaugă-inoindex. - Fă SDK-ul să returneze controlul pentru crawlere (sau când lipsește
device_id) — boții primesc o singură pagină stabilă.
Capcanele care te costă o seară
- Controlul nu face nimic. Ramura
controleNextResponse.next()— fără rewrite, fără pagină de variantă, cost zero pe calea fierbinte. Tocmai asimetria asta e ideea. - Forțează-l static. Marchează controlul și paginile de variantă drept
force-staticca să fie prerandate și puse în cache; un API dinamic rătăcit le coboară la render la fiecare cerere. - Proxy-ul rulează acum Node-first (v16). Ține-l slab — o citire de cookie, un apel de SDK, un rewrite — ca să rămână ieftin oriunde e deployat.
- Targetarea vine din cerere. Geo din header-ul CDN (
cf-ipcountry), limba din cale, UTM-urile din query — asamblează atributele în proxy și dă-le SDK-ului. - Planifică ieșirea. Când o variantă câștigă, integreaz-o în
/pricing, apoi șterge ruta de variantă și experimentul. Un test A/B lăsat pe veci e datorie tehnică cu un cookie.
Concluzii
- O pagină care e un test A/B poate rămâne 100% statică. Dinamic e doar care pagină statică — și de obicei e controlul, care nu cere deloc muncă.
- Rezolvă alocarea în proxy printr-o platformă de experimente, pe baza unui device id stabil — nu un random la fiecare cerere.
- Rescrie doar pentru variantele non-control; CDN-ul pune în cache controlul și fiecare variantă ca propria pagină. Fără
Vary: Cookie, fără hit la origin. - Un cookie device-id oferă bucketing persistent și atribuire; un cookie-firimitură scurt duce varianta la analiză.
- Protejează SEO-ul cu un canonical,
noindexpe ruta de variante și control-pentru-boți — apoi demontează experimentul când s-a terminat.
Ai găsit o greșeală?
Un fapt greșit, o traducere stângace, ceva ce sună fals în acest articol? Scrie-mi — în limba ta.