Skip to main content
Tilbake til bloggen
Next.jsA/B TestingProxyPerformanceSSG

En statisk side som i hemmelighet er en A/B-test

En A/B-test bygges vanligvis slik at siden faller bort fra CDN-en til en rendering per forespørsel. Slik trenger det ikke være. Hold hver variant helt statisk og la en proxy (Next.js sitt omdøpte middleware) løse tildelingen via en eksperimentplattform, ut fra en stabil device-id — og skriv bare om når besøkende ikke er i kontrollgruppen. URL-en endres aldri, CDN-en cacher fortsatt, og den vanlige veien koster ingenting.

Publisert 14. august 20268 min lesing

Hver gang du A/B-tester en side, gjør den enkle veien den dynamisk i det stille — og du mister CDN-en. Du trenger ikke gjøre den byttehandelen. Slik holder du hver variant statisk og gjemmer hele eksperimentet bak en proxy.

Konflikten

Du vil A/B-teste en side — priser, en landingshelt, et onboarding-steg. Så snart du gjør det, slutter den vanligvis å være statisk:

  • A/B på klienten sender JavaScript, blinker først originalvarianten og flytter layouten. Og det trenger JS for å i det hele tatt fungere.
  • Rendering per forespørsel fungerer, men hvert treff går nå til din origin — ingen CDN-cache, dårligere TTFB, mer beregning.
  • Vary: Cookie ser fristende ut, men fragmenterer cachen til én oppføring per cookie-verdi.

Men siden er i bunn og grunn fortsatt statisk HTML. Det eneste dynamiske er hvilken statisk HTML — og om den i det hele tatt skiller seg fra standarden. Isolér nettopp det.

Idéen

  • Hold kontrollgruppen som den ekte, statiske siden/pricing.
  • Hold hver ikke-kontroll-variant som sin egen statiske side under en dedikert rute.
  • I en proxy løser du besøkendes variant via et eksperiment-SDK. Kontroll? Gjør ingenting — den statiske siden rendres som den er. En variant? Skriv om til dens statiske side. URL-en er den samme i begge tilfeller.

Det smarte er asymmetrien: mesteparten av trafikken er kontroll, og kontroll koster null omskrivninger.

Den stabile identifikatoren

Konsistent bucketing trenger en stabil id, ikke et ferskt myntkast per forespørsel. En langlivet device_id-cookie holder — eksperiment-SDK-et hasher den (pluss eventuelle targeting-attributter), så den samme enheten alltid havner i den samme varianten og splitten holder seg på vektene du satte.

Den samme id-en er attribusjonsnøkkelen din: hver konvertering kan spores tilbake til bucketen enheten var i.

Sidene

Kontrollen er en vanlig statisk side. Variantene bor under en rute som ingenting lenker til direkte:

app/pricing/page.tsx
// The control — a plain static page.
export const dynamic = "force-static";

export default function Pricing() {
  return <Control />;
}
app/ab/pricing/[variant]/page.tsx
// 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 prerendres og caches uavhengig.

Proxyen — hele hemmeligheten

proxy.ts
// 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 eldre prosjekt er dette middleware.ts med export function middleware — samme logikk (proxy-dokumentasjon). Next.js har en codemod for å døpe den om: npx @next/codemod middleware-to-proxy .

Configen proxyen kjører på — cachet i minnet

Proxyen kan ikke magisk vite hvilke sider som testes. Det kartet — hvilken side som har et eksperiment, nøkkelen dens, sluggen til hver variant — bor i CMS-et ditt. Men du kan ikke hente det ved hver forespørsel (det setter CMS-et i den varme veien for hver sidelasting), og proxyens runtime gir deg ikke fetch-cachen du ville brukt i en Server Component. Så du melder deg av med cache: "no-store" og cacher selv, i minnet:

lib/experiments/config-cache.ts
// 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 fersk uten en nettverkstreff på den varme veien. Varm den én gang ved oppstart i instrumentation.ts — dens register() kjører én gang og må bli ferdig før serveren tar imot forespørsler, så den første besøkende betaler aldri for hentingen. Og oppdater den når innholdet endres, ikke på en timer: pek en CMS-webhook mot en rute som bygger kartet på nytt.

instrumentation.ts
// 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();
}
app/api/experiments/refresh/route.ts
// 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");
}

Ett forbehold som svir ved skala: denne cachen lever per instans. En enkelt webhook oppdaterer bare containeren som mottok den — hver annen container og region holder på sin egen utdaterte kopi. I en multiregion-deploy må du vifte invalideringen ut til hver instans så hver proxy bygger om — en broadcast til alle regionale deployer, eller en pub/sub instansene abonnerer på — pluss en CDN-purge for de berørte URL-ene. Den utviftingen er en infra-sak, ikke appkode. Ikke slik rørlegging? En kort TTL på cachen begrenser utdatertheten i stedet — enklere, bare ikke umiddelbart.

Hvorfor et eksperiment-SDK og ikke Math.random()

En egen random i proxyen deler teknisk sett trafikken, men du mister alt som gjør et eksperiment til å stole på. En plattform som GrowthBook (eller Statsig, LaunchDarkly, Unleash) gir deg:

  • Konsistent hashing per device_id, så en besøkende aldri hopper mellom varianter.
  • Vekter og gradvis utrulling du endrer uten deploy — 50/50, en 5 %-canary, opp til 100 %.
  • Targeting på attributtene du sender (land, språk, UTM, plan), evaluert på serveren.
  • En kill switch og ett sted der du ser hvert kjørende eksperiment.

Proxyen spør bare plattformen om en variant og skriver om. Eksperimentlogikken bor der den hører hjemme.

Hvorfor cachen fortsatt fungerer

CDN-en nøkler på omskrivingsmålet. Kontrollen er /pricing (cachet). Hver variant er /ab/pricing/b, /ab/pricing/c (cachet). Besøkende ser /pricing hele tiden. Ingen Vary: Cookie, ingen fragmentering per bruker — og kontrollen, majoritetsveien, skrives ikke engang om.

Analyse

Den kortlivde ab_pricing-cookien bærer den oppløste bucketen. Les den der du sender events — på serveren fra forespørselen, eller på klienten for å tagge analysen din — så hver konvertering attribueres til control, b eller c. Og siden forlater aldri CDN-en for å gjøre det.

Ikke la det lekke inn i SEO-en

  • Sett en canonical til /pricing på variantsidene, så søket konsoliderer på den ekte URL-en.
  • Hold /ab/* utenfor sitemapen din og gi den noindex.
  • La SDK-et returnere kontrollen for crawlere (eller når det mangler device_id) — boter får én stabil side.

Fellene som koster en kveld

  • Kontrollen gjør ingenting. control-grenen er NextResponse.next() — ingen omskriving, ingen variantside, null kostnad på den varme veien. Den asymmetrien er hele poenget.
  • Tving den statisk. Merk kontroll- og variantsidene som force-static så de prerendres og caches; et villfarent dynamisk API senker dem til rendering per forespørsel.
  • Proxyen kjører Node-først nå (v16). Hold den mager — én cookie-lesing, ett SDK-kall, én omskriving — så den forblir billig uansett hvor den deployes.
  • Targeting kommer fra forespørselen. Geo fra CDN-headeren (cf-ipcountry), språk fra stien, UTM-er fra query-en — sett sammen attributtene i proxyen og gi dem til SDK-et.
  • Planlegg utgangen. Når en variant vinner, flett den inn i /pricing, slett så variantruten og eksperimentet. En A/B-test som blir stående for alltid er teknisk gjeld med en cookie.

Oppsummering

  • En side som er en A/B-test kan fortsatt være 100 % statisk. Det dynamiske er bare hvilken statisk side — og som regel er det kontrollen, som ikke krever noe arbeid i det hele tatt.
  • Løs tildelingen i proxyen via en eksperimentplattform ut fra en stabil device-id — ikke en random per forespørsel.
  • Skriv om bare for ikke-kontroll-varianter; CDN-en cacher kontrollen og hver variant som sin egen side. Ingen Vary: Cookie, ingen origin-treff.
  • En device-id-cookie gir varig bucketing og attribusjon; en kort brødsmule-cookie bærer varianten til analysen.
  • Beskytt SEO-en med en canonical, noindex på variantruten og kontroll-for-boter — og riv ned eksperimentet når det er ferdig.

Fant du en feil?

Et feil faktum, en skjev oversettelse, noe som virker usant i denne artikkelen? Skriv til meg — på ditt eget språk.