En statisk side, der i hemmelighed er en A/B-test
En A/B-test bygges som regel sådan, at siden falder væk fra CDN'en til en rendering per forespørgsel. Sådan behøver det ikke være. Hold hver variant fuldt statisk og lad en proxy (Next.js' omdøbte middleware) løse tildelingen via en eksperimentplatform, ud fra et stabilt device-id — og skriv kun om, når den besøgende ikke er i kontrolgruppen. URL'en ændres aldrig, CDN'en cacher stadig, og den almindelige vej koster ingenting.
Hver gang du A/B-tester en side, gør den nemme vej den dynamisk i det stille — og du mister CDN'en. Du behøver ikke lave den byttehandel. Sådan holder du hver variant statisk og gemmer hele eksperimentet bag en proxy.
Konflikten
Du vil A/B-teste en side — priser, en landings-hero, et onboarding-trin. Så snart du gør det, holder den som regel op med at være statisk:
- A/B på klienten sender JavaScript, blinker først originalvarianten og flytter layoutet. Og det kræver JS for overhovedet at virke.
- Rendering per forespørgsel virker, men hvert hit går nu til din origin — ingen CDN-cache, dårligere TTFB, mere beregning.
Vary: Cookieser fristende ud, men fragmenterer cachen til én post per cookie-værdi.
Men siden er grundlæggende stadig statisk HTML. Det eneste dynamiske er hvilken statisk HTML — og om den overhovedet afviger fra standarden. Isolér præcis det.
Idéen
- Hold kontrolgruppen som den rigtige, statiske side på
/pricing. - Hold hver ikke-kontrol-variant som sin egen statiske side under en dedikeret rute.
- I en proxy løser du den besøgendes variant via et eksperiment-SDK. Kontrol? Gør ingenting — den statiske side renderes, som den er. En variant? Skriv om til dens statiske side. URL'en er den samme i begge tilfælde.
Det smarte er asymmetrien: det meste af trafikken er kontrol, og kontrol koster nul omskrivninger.
Den stabile identifikator
Konsistent bucketing kræver et stabilt id, ikke et nyt møntkast per forespørgsel. En langlivet device_id-cookie er nok — eksperiment-SDK'et hasher den (plus eventuelle targeting-attributter), så den samme enhed altid lander i den samme variant, og splittet bliver på de vægte, du satte.
Det samme id er din attributionsnøgle: hver konvertering kan spores tilbage til den bucket, enheden var i.
Siderne
Kontrollen er en almindelig statisk side. Varianterne bor under en rute, som intet linker til direkte:
// 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 />;
}Kontrollen og hver variant prerenderes og caches uafhængigt.
Proxyen — hele hemmeligheden
// 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;
}I et ældre projekt er dette middleware.ts med export function middleware — samme logik (proxy-dokumentation). Next.js har en codemod til at omdøbe den: npx @next/codemod middleware-to-proxy .
Den config, proxyen kører på — cachet i hukommelsen
Proxyen kan ikke magisk vide, hvilke sider der testes. Det kort — hvilken side der har et eksperiment, dens nøgle, sluggen for hver variant — bor i dit CMS. Men du kan ikke hente det ved hver forespørgsel (det sætter CMS'et i den varme vej for hver sideindlæsning), og proxyens runtime giver dig ikke den fetch-cache, du ville bruge i en Server Component. Så du melder dig fra med cache: "no-store" og cacher selv, i hukommelsen:
// 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);
}To ting holder den frisk uden et netværkshit på den varme vej. Varm den én gang ved opstart i instrumentation.ts — dens register() kører én gang og skal blive færdig, før serveren tager imod forespørgsler, så den første besøgende aldrig betaler for hentningen. Og opdater den, når indholdet ændres, ikke på en timer: peg en CMS-webhook mod en rute, der genopbygger kortet.
// 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");
}Et forbehold, der bider ved skala: denne cache lever per instans. En enkelt webhook opdaterer kun den container, der modtog den — hver anden container og region holder på sin egen forældede kopi. I en multiregion-deployment skal du vifte invalideringen ud til hver instans, så hver proxy genopbygger — en broadcast til alle regionale deployments, eller en pub/sub, som instanserne abonnerer på — plus en CDN-purge for de berørte URL'er. Den udviftning er en infra-sag, ikke app-kode. Ingen sådan rørføring? En kort TTL på cachen begrænser forældelsen i stedet — enklere, bare ikke øjeblikkeligt.
Hvorfor et eksperiment-SDK og ikke Math.random()
En egen random i proxyen deler teknisk set trafikken, men du mister alt, hvad der gør et eksperiment troværdigt. En platform som GrowthBook (eller Statsig, LaunchDarkly, Unleash) giver dig:
- Konsistent hashing per
device_id, så en besøgende aldrig hopper mellem varianter. - Vægte og gradvis udrulning, du ændrer uden deploy — 50/50, en 5 %-canary, op til 100 %.
- Targeting på de attributter, du sender (land, sprog, UTM, plan), evalueret på serveren.
- En kill switch og ét sted, hvor du ser hvert kørende eksperiment.
Proxyen spørger bare platformen om en variant og skriver om. Eksperimentlogikken bor der, hvor den hører hjemme.
Hvorfor cachen stadig virker
CDN'en nøgler på omskrivningsmålet. Kontrollen er /pricing (cachet). Hver variant er /ab/pricing/b, /ab/pricing/c (cachet). Den besøgende ser /pricing hele tiden. Ingen Vary: Cookie, ingen fragmentering per bruger — og kontrollen, flertalsvejen, skrives ikke engang om.
Analyse
Den kortlivede ab_pricing-cookie bærer den opløste bucket. Læs den, hvor du sender events — på serveren fra forespørgslen, eller på klienten for at tagge din analyse — så hver konvertering attribueres til control, b eller c. Og siden forlader aldrig CDN'en for at gøre det.
Lad det ikke sive ind i SEO'en
- Sæt en canonical til
/pricingpå variantsiderne, så søgningen konsoliderer på den rigtige URL. - Hold
/ab/*uden for dit sitemap og giv dennoindex. - Lad SDK'et returnere kontrollen for crawlere (eller når der mangler
device_id) — bots får én stabil side.
Fælderne, der koster en aften
- Kontrollen gør ingenting.
control-grenen erNextResponse.next()— ingen omskrivning, ingen variantside, nul omkostning på den varme vej. Netop den asymmetri er pointen. - Tving den statisk. Markér kontrol- og variantsiderne som
force-static, så de prerenderes og caches; et vildfarent dynamisk API sænker dem til rendering per forespørgsel. - Proxyen kører Node-først nu (v16). Hold den mager — én cookie-læsning, ét SDK-kald, én omskrivning — så den forbliver billig, uanset hvor den deployes.
- Targeting kommer fra forespørgslen. Geo fra CDN-headeren (
cf-ipcountry), sprog fra stien, UTM'er fra query'en — saml attributterne i proxyen og giv dem til SDK'et. - Planlæg exit'en. Når en variant vinder, flet den ind i
/pricing, og slet så variantruten og eksperimentet. En A/B-test, der får lov at køre for evigt, er teknisk gæld med en cookie.
Konklusioner
- En side, der er en A/B-test, kan stadig være 100 % statisk. Det dynamiske er bare hvilken statisk side — og som regel er det kontrollen, der slet ikke kræver arbejde.
- Løs tildelingen i proxyen via en eksperimentplatform ud fra et stabilt device-id — ikke en random per forespørgsel.
- Skriv kun om for ikke-kontrol-varianter; CDN'en cacher kontrollen og hver variant som sin egen side. Ingen
Vary: Cookie, intet origin-hit. - En device-id-cookie giver vedvarende bucketing og attribution; en kort brødkrumme-cookie bærer varianten til analysen.
- Beskyt SEO'en med en canonical,
noindexpå variantruten og kontrol-for-bots — og riv eksperimentet ned, når det er færdigt.
Fandt du en fejl?
Et forkert faktum, en skæv oversættelse, noget der virker usandt i denne artikel? Skriv til mig — på dit eget sprog.