Skip to main content
Retour au blog
Next.jsA/B TestingProxyPerformanceSSG

Une page statique qui est secrètement un test A/B

Un test A/B est en général monté de sorte que la page tombe du CDN vers un rendu à chaque requête. Ce n'est pas une fatalité. Garde chaque variante entièrement statique et laisse un proxy (le middleware renommé de Next.js) résoudre l'affectation via une plateforme d'expérimentation, à partir d'un device id stable — puis réécris seulement quand le visiteur n'est pas dans le contrôle. L'URL ne change jamais, le CDN met toujours en cache, et le chemin courant ne coûte rien.

Publié 14 août 20268 min de lecture

Chaque fois que tu fais un test A/B d'une page, la voie facile la rend dynamique en douce — et tu perds le CDN. Tu n'es pas obligé de faire ce compromis. Voici comment garder chaque variante statique et cacher toute l'expérimentation derrière un proxy.

La tension

Tu veux tester une page en A/B — les prix, un hero de landing, une étape d'onboarding. Dès que tu le fais, elle cesse généralement d'être statique :

  • L'A/B côté client livre du JavaScript, affiche d'abord la variante d'origine et décale la mise en page. Et il lui faut du JS pour fonctionner.
  • Le rendu à chaque requête marche, mais chaque hit part désormais vers ton origin — pas de cache CDN, un TTFB dégradé, plus de calcul.
  • Vary: Cookie semble tentant mais fragmente le cache en une entrée par valeur de cookie.

Mais la page reste, au fond, du HTML statique. La seule chose dynamique, c'est quel HTML statique — et s'il diffère seulement du défaut. Isole exactement ça.

L'idée

  • Garde le contrôle comme la vraie page statique sur /pricing.
  • Garde chaque variante hors contrôle comme sa propre page statique sous une route dédiée.
  • Dans un proxy, résous la variante du visiteur via un SDK d'expérimentation. Contrôle ? Ne fais rien — la page statique s'affiche telle quelle. Une variante ? Réécris vers sa page statique. L'URL reste la même dans les deux cas.

Le plus malin, c'est l'asymétrie : l'essentiel du trafic est le contrôle, et le contrôle ne coûte aucun rewrite.

L'identifiant stable

Un bucketing cohérent a besoin d'un id stable, pas d'un nouveau lancer de pièce à chaque requête. Un cookie device_id de longue durée suffit — le SDK d'expérimentation le hache (plus les attributs de ciblage), si bien que le même appareil tombe toujours dans la même variante et que la répartition reste aux poids que tu as fixés.

Ce même id est ta clé d'attribution : chaque conversion peut être remontée jusqu'au bucket dans lequel se trouvait l'appareil.

Les pages

Le contrôle est une page statique normale. Les variantes vivent sous une route vers laquelle rien ne pointe directement :

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 />;
}

Le contrôle et chaque variante sont prérendus et mis en cache indépendamment.

Le proxy — tout le secret

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

Sur un projet plus ancien, c'est middleware.ts avec export function middleware — la même logique (docs proxy). Next.js fournit un codemod pour le renommer : npx @next/codemod middleware-to-proxy .

La config sur laquelle tourne le proxy — mise en cache en mémoire

Le proxy ne peut pas deviner par magie quelles pages sont sous test. Cette carte — quelle page a une expérience, sa clé, le slug de chaque variante — vit dans ton CMS. Mais tu ne peux pas la fetcher à chaque requête (ça met le CMS dans le chemin chaud de chaque chargement), et le runtime du proxy ne te donne pas le cache fetch que tu utiliserais dans un Server Component. Alors tu t'en désinscris avec cache: "no-store" et tu la mets en cache toi-même, en mémoire :

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

Deux choses la gardent fraîche sans hit réseau sur le chemin chaud. Préchauffe-la une fois au démarrage dans instrumentation.ts — son register() s'exécute une fois et doit se terminer avant que le serveur accepte les requêtes, si bien que le premier visiteur ne paie jamais le fetch. Et rafraîchis-la quand le contenu change, pas sur un minuteur : pointe un webhook du CMS vers une route qui reconstruit la carte.

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

Un bémol qui mord à l'échelle : ce cache vit par instance. Un seul webhook ne rafraîchit que le conteneur qui l'a reçu — chaque autre conteneur et région garde sa propre copie périmée. Dans un déploiement multi-région, tu dois diffuser l'invalidation à chaque instance pour que chaque proxy se reconstruise — un broadcast vers tous les déploiements régionaux, ou un pub/sub auquel les instances s'abonnent — plus une purge CDN pour les URL concernées. Ce fan-out relève de l'infra, pas du code applicatif. Pas cette plomberie ? Un TTL court sur le cache borne la péremption à la place — plus simple, juste pas instantané.

Pourquoi un SDK d'expérimentation, pas Math.random()

Un random maison dans le proxy répartit techniquement le trafic, mais tu perds tout ce qui rend une expérience fiable. Une plateforme comme GrowthBook (ou Statsig, LaunchDarkly, Unleash) te donne :

  • Un hachage cohérent par device_id, pour qu'un visiteur ne saute jamais d'une variante à l'autre.
  • Des poids et un déploiement progressif que tu changes sans déployer — 50/50, un canari à 5 %, montée à 100 %.
  • Du ciblage sur les attributs que tu passes (pays, langue, UTM, plan), évalué côté serveur.
  • Un kill switch et un seul endroit où voir chaque expérience en cours.

Le proxy demande juste une variante à la plateforme et réécrit. La logique d'expérimentation vit là où elle doit être.

Pourquoi le cache marche toujours

Le CDN indexe sur la cible du rewrite. Le contrôle est /pricing (en cache). Chaque variante est /ab/pricing/b, /ab/pricing/c (en cache). Le visiteur voit /pricing tout du long. Pas de Vary: Cookie, pas de fragmentation par utilisateur — et le contrôle, le chemin majoritaire, n'est même pas réécrit.

Analytics

Le cookie éphémère ab_pricing porte le bucket résolu. Lis-le partout où tu émets des événements — côté serveur depuis la requête, ou côté client pour taguer ton analytics — pour que chaque conversion soit attribuée à control, b ou c. Et la page ne quitte jamais le CDN pour autant.

Ne le laisse pas fuir dans le SEO

  • Mets un canonical vers /pricing sur les pages de variante, pour que la recherche se consolide sur la vraie URL.
  • Garde /ab/* hors de ton sitemap et ajoute-lui un noindex.
  • Fais que le SDK renvoie le contrôle pour les crawlers (ou en l'absence de device_id) — les bots ont une seule page stable.

Les pièges qui coûtent une soirée

  • Le contrôle ne fait rien. La branche control est NextResponse.next() — pas de rewrite, pas de page de variante, coût nul sur le chemin chaud. C'est cette asymétrie, l'astuce.
  • Force le statique. Marque le contrôle et les pages de variante en force-static pour qu'ils soient prérendus et mis en cache ; une API dynamique égarée les fait retomber en rendu à chaque requête.
  • Le proxy tourne désormais en Node d'abord (v16). Garde-le maigre — une lecture de cookie, un appel SDK, un rewrite — pour qu'il reste peu coûteux partout où il est déployé.
  • Le ciblage vient de la requête. La géo depuis le header du CDN (cf-ipcountry), la langue depuis le chemin, les UTM depuis la query — assemble les attributs dans le proxy et passe-les au SDK.
  • Prévois la sortie. Quand une variante gagne, fusionne-la dans /pricing, puis supprime la route de variante et l'expérience. Un test A/B laissé pour toujours, c'est de la dette technique avec un cookie.

À retenir

  • Une page qui est un test A/B peut rester 100 % statique. Le dynamique, c'est juste quelle page statique — et c'est le plus souvent le contrôle, qui ne demande aucun travail.
  • Résous l'affectation dans le proxy via une plateforme d'expérimentation à partir d'un device id stable — pas un random par requête.
  • Réécris seulement pour les variantes hors contrôle ; le CDN met en cache le contrôle et chaque variante comme sa propre page. Pas de Vary: Cookie, pas de hit à l'origin.
  • Un cookie device-id donne un bucketing persistant et l'attribution ; un petit cookie-miette porte la variante jusqu'à l'analytics.
  • Protège le SEO avec un canonical, un noindex sur la route de variantes et le contrôle-pour-bots — puis démonte l'expérience quand elle est finie.

Vous voyez une erreur ?

Un fait erroné, une traduction bancale, quelque chose qui sonne faux dans cet article ? Écrivez-moi — dans votre propre langue.