Eine statische Seite, die heimlich ein A/B-Test ist
Ein A/B-Test wird meist so gebaut, dass die Seite vom CDN auf ein Rendering pro Request fällt. Muss aber nicht sein. Halte jede Variante vollständig statisch und lass einen Proxy (Next.js' umbenanntes Middleware) die Zuordnung über eine Experiment-Plattform auflösen, anhand einer stabilen Device-ID — und schreibe nur um, wenn der Besucher nicht in der Kontrollgruppe ist. Die URL ändert sich nie, das CDN cached weiter, und der häufige Pfad kostet nichts.
Jedes Mal, wenn du eine Seite A/B-testest, macht der einfache Weg sie still dynamisch — und du verlierst das CDN. Diesen Kompromiss musst du nicht eingehen. So bleibt jede Variante statisch und das ganze Experiment versteckt sich hinter einem Proxy.
Der Zielkonflikt
Du willst eine Seite A/B-testen — Preise, ein Landing-Hero, einen Onboarding-Schritt. Sobald du das tust, hört sie meist auf, statisch zu sein:
- Client-seitiges A/B liefert JavaScript aus, zeigt kurz die Originalvariante und verschiebt das Layout. Und ohne JS funktioniert es gar nicht.
- Rendering pro Request funktioniert, aber jeder Hit geht nun an deinen Origin — kein CDN-Cache, schlechterer TTFB, mehr Compute.
Vary: Cookiewirkt verlockend, zerteilt den Cache aber in einen Eintrag pro Cookie-Wert.
Aber die Seite ist im Grunde weiterhin statisches HTML. Das einzig Dynamische ist, welches statische HTML — und ob es überhaupt vom Standard abweicht. Isoliere genau das.
Die Idee
- Halte die Kontrollgruppe als die echte, statische Seite unter
/pricing. - Halte jede Nicht-Kontroll-Variante als eigene statische Seite unter einer eigenen Route.
- Löse im Proxy die Variante des Besuchers über ein Experiment-SDK auf. Kontrolle? Tu nichts — die statische Seite rendert unverändert. Eine Variante? Schreibe um auf deren statische Seite. Die URL bleibt in beiden Fällen gleich.
Das Clevere ist die Asymmetrie: der meiste Traffic ist Kontrolle, und Kontrolle kostet null Rewrites.
Der stabile Identifier
Konsistentes Bucketing braucht eine stabile ID, keinen frischen Münzwurf pro Request. Ein langlebiges device_id-Cookie reicht — das Experiment-SDK hasht es (plus etwaige Targeting-Attribute), sodass dasselbe Gerät stets in derselben Variante landet und der Split bei den von dir gesetzten Gewichten bleibt.
Dieselbe ID ist dein Attributions-Schlüssel: jede Conversion lässt sich zum Bucket zurückverfolgen, in dem das Gerät war.
Die Seiten
Die Kontrolle ist eine normale statische Seite. Die Varianten liegen unter einer Route, auf die nichts direkt verlinkt:
// 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 />;
}Kontrolle und jede Variante werden vorgerendert und unabhängig gecached.
Der Proxy — das ganze Geheimnis
// 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;
}In einem älteren Projekt ist das middleware.ts mit export function middleware — dieselbe Logik (Proxy-Docs). Next.js liefert einen Codemod zum Umbenennen: npx @next/codemod middleware-to-proxy .
Die Config, auf der der Proxy läuft — im Speicher gecached
Der Proxy kann nicht magisch wissen, welche Seiten unter Test sind. Diese Map — welche Seite ein Experiment hat, ihren Key, den Slug jeder Variante — liegt in deiner CMS. Aber du kannst sie nicht bei jedem Request fetchen (das setzt die CMS in den heißen Pfad jedes Seitenaufrufs), und die Proxy-Laufzeit gibt dir nicht den fetch-Cache, den du in einer Server Component nutzen würdest. Also steigst du mit cache: "no-store" aus und cachest selbst, im Speicher:
// 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);
}Zwei Dinge halten sie frisch, ohne einen Netzwerk-Hit auf dem heißen Pfad. Wärme sie einmal beim Start in instrumentation.ts vor — dessen register() läuft einmal und muss fertig sein, bevor der Server Requests annimmt, sodass der erste Besucher nie für den Fetch zahlt. Und aktualisiere sie, wenn sich Inhalte ändern, nicht per Timer: richte einen CMS-Webhook auf eine Route, die die Map neu aufbaut.
// 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");
}Ein Vorbehalt, der bei Skalierung zubeißt: dieser Cache lebt pro Instanz. Ein einzelner Webhook aktualisiert nur den Container, der ihn erhalten hat — jeder andere Container und jede Region hält weiter seine eigene veraltete Kopie. In einem Multi-Region-Deployment musst du die Invalidierung an jede Instanz ausfächern, damit jeder Proxy neu aufbaut — ein Broadcast an alle regionalen Deployments oder ein Pub/Sub, das die Instanzen abonnieren — plus ein CDN-Purge für die betroffenen URLs. Dieser Fan-out ist eine Infra-Sache, kein App-Code. Keine solche Verkabelung? Ein kurzes TTL auf dem Cache begrenzt stattdessen die Veraltung — einfacher, nur nicht sofort.
Warum ein Experiment-SDK und kein Math.random()
Ein eigenes Random im Proxy teilt den Traffic technisch auf, aber du verlierst alles, was ein Experiment vertrauenswürdig macht. Eine Plattform wie GrowthBook (oder Statsig, LaunchDarkly, Unleash) gibt dir:
- Konsistentes Hashing per
device_id, sodass ein Besucher nie zwischen Varianten hin- und herspringt. - Gewichte und schrittweises Rollout, die du ohne Deploy änderst — 50/50, ein 5%-Canary, Hochfahren auf 100%.
- Targeting anhand der übergebenen Attribute (Land, Locale, UTM, Plan), serverseitig ausgewertet.
- Einen Kill-Switch und einen Ort, an dem du jedes laufende Experiment siehst.
Der Proxy fragt die Plattform nur nach einer Variante und schreibt um. Die Experiment-Logik lebt dort, wo sie hingehört.
Warum der Cache weiter funktioniert
Das CDN keyt auf das Rewrite-Ziel. Kontrolle ist /pricing (gecached). Jede Variante ist /ab/pricing/b, /ab/pricing/c (gecached). Der Besucher sieht durchgehend /pricing. Kein Vary: Cookie, keine Fragmentierung pro Nutzer — und die Kontrolle, der Mehrheitspfad, wird nicht einmal umgeschrieben.
Analytics
Das kurzlebige ab_pricing-Cookie trägt den aufgelösten Bucket. Lies es, wo immer du Events sendest — serverseitig aus dem Request oder clientseitig, um deine Analytics zu taggen — sodass jede Conversion control, b oder c zugeordnet wird. Und dafür verlässt die Seite nie das CDN.
Lass es nicht ins SEO durchsickern
- Setze auf den Varianten-Seiten ein Canonical auf
/pricing, damit Suchmaschinen sich auf die echte URL konsolidieren. - Halte
/ab/*aus deiner Sitemap heraus und gib ihm einnoindex. - Lass das SDK für Crawler die Kontrolle zurückgeben (oder wenn es keine
device_idgibt) — Bots bekommen eine stabile Seite.
Die Fallstricke, die einen Abend kosten
- Die Kontrolle tut nichts. Der
control-Zweig istNextResponse.next()— kein Rewrite, keine Varianten-Seite, keine Kosten auf dem heißen Pfad. Genau diese Asymmetrie ist der Punkt. - Erzwinge statisch. Markiere Kontroll- und Varianten-Seiten als
force-static, damit sie vorgerendert und gecached werden; ein versehentliches dynamisches API kippt sie auf Rendering pro Request. - Der Proxy läuft jetzt Node-first (v16). Halte ihn schlank — ein Cookie-Read, ein SDK-Aufruf, ein Rewrite — damit er überall günstig bleibt, wo er deployt wird.
- Targeting kommt aus dem Request. Geo aus dem CDN-Header (
cf-ipcountry), Locale aus dem Pfad, UTMs aus der Query — stelle die Attribute im Proxy zusammen und gib sie ans SDK. - Plane den Ausstieg. Gewinnt eine Variante, führe sie in
/pricingzusammen, lösche dann die Varianten-Route und das Experiment. Ein für immer laufender A/B-Test ist Tech-Debt mit Cookie.
Fazit
- Eine Seite, die ein A/B-Test ist, kann trotzdem 100% statisch sein. Dynamisch ist nur, welche statische Seite — und meist ist es die Kontrolle, die gar keine Arbeit macht.
- Löse die Zuordnung im Proxy über eine Experiment-Plattform anhand einer stabilen Device-ID auf — kein Random pro Request.
- Schreibe nur für Nicht-Kontroll-Varianten um; das CDN cached Kontrolle und jede Variante als eigene Seite. Kein
Vary: Cookie, kein Origin-Hit. - Ein Device-ID-Cookie liefert stabiles Bucketing und Attribution; ein kurzes Breadcrumb-Cookie trägt die Variante zur Analytics.
- Schütze SEO mit einem Canonical,
noindexauf der Varianten-Route und Kontrolle-für-Bots — und baue das Experiment ab, wenn es fertig ist.
Einen Fehler entdeckt?
Ein falscher Fakt, eine schiefe Übersetzung, etwas, das in diesem Artikel falsch klingt? Schreib mir — in deiner eigenen Sprache.