Skip to main content
Tillbaka till bloggen
Next.jsA/B TestingProxyPerformanceSSG

En statisk sida som i hemlighet är ett A/B-test

Ett A/B-test byggs oftast så att sidan faller bort från CDN:en till en rendering per request. Så behöver det inte vara. Håll varje variant helt statisk och låt en proxy (Next.js omdöpta middleware) lösa tilldelningen via en experimentplattform, utifrån ett stabilt device-id — och skriv om bara när besökaren inte är i kontrollgruppen. URL:en ändras aldrig, CDN:en cachar fortfarande, och den vanliga vägen kostar ingenting.

Publicerad 14 augusti 20268 min läsning

Varje gång du A/B-testar en sida gör den enkla vägen den dynamisk i smyg — och du förlorar CDN:en. Du behöver inte göra den bytesaffären. Så här håller du varje variant statisk och gömmer hela experimentet bakom en proxy.

Konflikten

Du vill A/B-testa en sida — priser, en landningshjälte, ett onboarding-steg. Så snart du gör det slutar den oftast vara statisk:

  • A/B på klienten skickar JavaScript, blinkar först originalvarianten och flyttar layouten. Och det behöver JS för att alls fungera.
  • Rendering per request fungerar, men varje träff går nu till din origin — ingen CDN-cache, sämre TTFB, mer beräkning.
  • Vary: Cookie ser lockande ut men fragmenterar cachen till en post per cookie-värde.

Men sidan är i grunden fortfarande statisk HTML. Det enda dynamiska är vilken statisk HTML — och om den ens skiljer sig från standard. Isolera precis det.

Idén

  • Håll kontrollgruppen som den riktiga, statiska sidan/pricing.
  • Håll varje icke-kontroll-variant som sin egen statiska sida under en dedikerad rutt.
  • Lös i en proxy besökarens variant via ett experiment-SDK. Kontroll? Gör ingenting — den statiska sidan renderas som den är. En variant? Skriv om till dess statiska sida. URL:en är densamma i båda fallen.

Det smarta är asymmetrin: det mesta av trafiken är kontroll, och kontroll kostar noll omskrivningar.

Den stabila identifieraren

Konsekvent bucketing behöver ett stabilt id, inte ett nytt myntkast per request. En långlivad device_id-cookie räcker — experiment-SDK:t hashar den (plus eventuella targeting-attribut), så att samma enhet alltid hamnar i samma variant och splitten stannar på de vikter du satt.

Samma id är din attributionsnyckel: varje konvertering kan spåras tillbaka till bucketen enheten var i.

Sidorna

Kontrollen är en vanlig statisk sida. Varianterna bor under en rutt som inget länkar till direkt:

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 och varje variant prerendras och cachas oberoende.

Proxyn — hela hemligheten

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 ett äldre projekt är detta middleware.ts med export function middleware — samma logik (proxy-dokumentation). Next.js har en codemod för att döpa om den: npx @next/codemod middleware-to-proxy .

Configen som proxyn kör på — cachad i minnet

Proxyn kan inte magiskt veta vilka sidor som testas. Den kartan — vilken sida som har ett experiment, dess nyckel, varje variants slug — bor i ditt CMS. Men du kan inte hämta den vid varje request (det sätter CMS:et i den heta vägen för varje sidladdning), och proxyns runtime ger dig inte den fetch-cache du skulle använda i en Server Component. Så du väljer bort den med cache: "no-store" och cachar själv, 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);
}

Två saker håller den färsk utan en nätverksträff på den heta vägen. Värm den en gång vid start i instrumentation.ts — dess register() körs en gång och måste bli klar innan servern tar emot requests, så den första besökaren betalar aldrig för hämtningen. Och uppdatera den när innehållet ändras, inte på en timer: peka en CMS-webhook mot en rutt som bygger om kartan.

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");
}

En brasklapp som biter vid skala: den här cachen lever per instans. En enda webhook uppdaterar bara containern som tog emot den — varje annan container och region håller kvar sin egen inaktuella kopia. I en multiregion-deploy måste du fläkta ut invalideringen till varje instans så att varje proxy bygger om — en broadcast till alla regionala deployer, eller en pub/sub som instanserna prenumererar på — plus en CDN-purge för de berörda URL:erna. Den utfläkningen är en infra-sak, inte appkod. Ingen sådan rörmokeri? En kort TTL på cachen begränsar inaktualiteten istället — enklare, bara inte omedelbart.

Varför ett experiment-SDK och inte Math.random()

En egen random i proxyn delar tekniskt sett trafiken, men du förlorar allt som gör ett experiment pålitligt. En plattform som GrowthBook (eller Statsig, LaunchDarkly, Unleash) ger dig:

  • Konsekvent hashning per device_id, så att en besökare aldrig hoppar mellan varianter.
  • Vikter och gradvis utrullning som du ändrar utan deploy — 50/50, en 5%-canary, upp till 100%.
  • Targeting på attributen du skickar (land, språk, UTM, plan), utvärderat på servern.
  • En kill switch och ett enda ställe där du ser varje pågående experiment.

Proxyn frågar bara plattformen efter en variant och skriver om. Experimentlogiken bor där den hör hemma.

Varför cachen fortfarande fungerar

CDN:en nycklar på omskrivningsmålet. Kontrollen är /pricing (cachad). Varje variant är /ab/pricing/b, /ab/pricing/c (cachade). Besökaren ser /pricing hela tiden. Ingen Vary: Cookie, ingen fragmentering per användare — och kontrollen, majoritetsvägen, skrivs inte ens om.

Analys

Den kortlivade ab_pricing-cookien bär den upplösta bucketen. Läs den där du skickar events — på servern från requesten, eller på klienten för att tagga din analys — så att varje konvertering attribueras till control, b eller c. Och sidan lämnar aldrig CDN:en för att göra det.

Låt det inte läcka ut i SEO:n

  • Sätt en canonical till /pricing på variantsidorna, så att sökmotorn konsoliderar på den riktiga URL:en.
  • Håll /ab/* utanför din sitemap och ge den noindex.
  • Låt SDK:t returnera kontrollen för crawlers (eller när det saknas device_id) — botar får en stabil sida.

Fällorna som kostar en kväll

  • Kontrollen gör ingenting. control-grenen är NextResponse.next() — ingen omskrivning, ingen variantsida, noll kostnad på den heta vägen. Den asymmetrin är hela poängen.
  • Tvinga fram statisk. Markera kontroll- och variantsidorna som force-static så att de prerendras och cachas; ett vilset dynamiskt API sänker dem till rendering per request.
  • Proxyn kör Node-först nu (v16). Håll den mager — en cookie-läsning, ett SDK-anrop, en omskrivning — så att den förblir billig var den än deployas.
  • Targeting kommer från requesten. Geo från CDN-headern (cf-ipcountry), språk från sökvägen, UTM:er från query — sätt ihop attributen i proxyn och ge dem till SDK:t.
  • Planera utgången. När en variant vinner, foga in den i /pricing, radera sedan variantrutten och experimentet. Ett A/B-test som lämnas kvar för evigt är teknisk skuld med en cookie.

Slutsatser

  • En sida som är ett A/B-test kan fortfarande vara 100% statisk. Det dynamiska är bara vilken statisk sida — och oftast är det kontrollen, som inte kräver något arbete alls.
  • Lös tilldelningen i proxyn via en experimentplattform utifrån ett stabilt device-id — inte en random per request.
  • Skriv om bara för icke-kontroll-varianter; CDN:en cachar kontrollen och varje variant som sin egen sida. Ingen Vary: Cookie, ingen origin-träff.
  • En device-id-cookie ger beständig bucketing och attribution; en kort brödsmulecookie bär varianten till analysen.
  • Skydda SEO:n med en canonical, noindex på variantrutten och kontroll-för-botar — och riv ner experimentet när det är klart.

Hittade du ett fel?

Ett felaktigt faktum, en skev översättning, något som känns falskt i den här artikeln? Skriv till mig — på ditt eget språk.