Skip to main content
Volver al blog
Next.jsA/B TestingProxyPerformanceSSG

Una página estática que en secreto es un test A/B

Un test A/B suele montarse de forma que la página se cae del CDN a un render por petición. No tiene por qué. Mantén cada variante totalmente estática y deja que un proxy (el middleware renombrado de Next.js) resuelva la asignación con una plataforma de experimentos, según un device id estable — y reescribe solo cuando el visitante no está en el control. La URL nunca cambia, el CDN sigue cacheando y el camino habitual no cuesta nada.

Publicado 14 de agosto de 20268 min de lectura

Cada vez que haces un test A/B de una página, el camino fácil la vuelve dinámica sin avisar — y pierdes el CDN. No tienes que hacer ese cambio. Así mantienes cada variante estática y escondes todo el experimento tras un proxy.

La tensión

Quieres hacer un test A/B de una página — precios, un hero de landing, un paso de onboarding. En cuanto lo haces, suele dejar de ser estática:

  • A/B en cliente envía JavaScript, muestra primero la variante original y desplaza el layout. Además necesita JS para funcionar.
  • Render por petición funciona, pero ahora cada hit va a tu origin — sin caché de CDN, peor TTFB, más cómputo.
  • Vary: Cookie resulta tentador pero fragmenta la caché en una entrada por cada valor de cookie.

Pero la página sigue siendo, en el fondo, HTML estático. Lo único dinámico es cuál HTML estático — y si acaso difiere del predeterminado. Aísla exactamente eso.

La idea

  • Mantén el control como la página real y estática en /pricing.
  • Mantén cada variante que no sea el control como su propia página estática bajo una ruta dedicada.
  • En un proxy, resuelve la variante del visitante con un SDK de experimentos. ¿Control? No hagas nada — la página estática se renderiza tal cual. ¿Una variante? Reescribe a la página estática de esa variante. La URL es la misma en ambos casos.

Lo ingenioso es la asimetría: la mayoría del tráfico es control, y el control no cuesta ni un rewrite.

El identificador estable

El bucketing consistente necesita un id estable, no un nuevo lanzamiento de moneda en cada petición. Basta con una cookie device_id de larga duración — el SDK de experimentos la hashea (más los atributos de targeting), de modo que el mismo dispositivo cae siempre en la misma variante y el reparto se mantiene en los pesos que fijaste.

Ese mismo id es tu clave de atribución: cada conversión puede rastrearse hasta el bucket en el que estaba el dispositivo.

Las páginas

El control es una página estática normal. Las variantes viven bajo una ruta a la que nada enlaza directamente:

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

El control y cada variante se prerrenderizan y se cachean de forma independiente.

El proxy — todo el secreto

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

En un proyecto más antiguo esto es middleware.ts con export function middleware — la misma lógica (docs de proxy). Next.js incluye un codemod para renombrarlo: npx @next/codemod middleware-to-proxy .

La config sobre la que corre el proxy — cacheada en memoria

El proxy no puede saber por arte de magia qué páginas están bajo test. Ese mapa — qué página tiene un experimento, su clave, el slug de cada variante — vive en tu CMS. Pero no puedes traerlo en cada petición (eso mete al CMS en el camino caliente de cada carga), y el runtime del proxy no te da la caché de fetch que usarías en un Server Component. Así que te sales con cache: "no-store" y lo cacheas tú mismo, en memoria:

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

Dos cosas lo mantienen fresco sin un hit de red en el camino caliente. Caliéntalo una vez al arrancar en instrumentation.ts — su register() corre una vez y debe terminar antes de que el servidor acepte peticiones, así que el primer visitante nunca paga por el fetch. Y refréscalo cuando cambie el contenido, no por temporizador: apunta un webhook del CMS a una ruta que reconstruya el mapa.

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

Una salvedad que muerde a escala: esta caché vive por instancia. Un solo webhook solo refresca el contenedor que lo recibió — cada otro contenedor y región sigue con su propia copia obsoleta. En un despliegue multirregión tienes que abanicar la invalidación a cada instancia para que cada proxy se reconstruya — un broadcast a todos los despliegues regionales, o un pub/sub al que se suscriban las instancias — además de un purgado de CDN para las URLs afectadas. Ese fan-out es cosa de infra, no del código de la app. ¿No tienes esa fontanería? Un TTL corto en la caché acota la obsolescencia en su lugar — más simple, solo que no instantáneo.

Por qué un SDK de experimentos y no Math.random()

Tu propio random en el proxy técnicamente reparte el tráfico, pero pierdes todo lo que hace fiable a un experimento. Una plataforma como GrowthBook (o Statsig, LaunchDarkly, Unleash) te da:

  • Hashing consistente por device_id, para que un visitante nunca salte entre variantes.
  • Pesos y despliegue gradual que cambias sin desplegar — 50/50, un canario del 5%, subir al 100%.
  • Targeting según los atributos que pasas (país, idioma, UTM, plan), evaluado en el servidor.
  • Un kill switch y un único sitio donde ver todos los experimentos en marcha.

El proxy solo le pide una variante a la plataforma y reescribe. La lógica de experimentación vive donde le corresponde.

Por qué la caché sigue funcionando

El CDN cachea por el destino del rewrite. El control es /pricing (cacheado). Cada variante es /ab/pricing/b, /ab/pricing/c (cacheadas). El visitante ve /pricing todo el tiempo. Sin Vary: Cookie, sin fragmentación por usuario — y el control, el camino mayoritario, ni siquiera se reescribe.

Analítica

La cookie efímera ab_pricing lleva el bucket resuelto. Léela donde emitas eventos — en el servidor desde la petición, o en el cliente para etiquetar tu analítica — de modo que cada conversión se atribuya a control, b o c. Y la página nunca sale del CDN para lograrlo.

No dejes que se filtre al SEO

  • Pon un canonical a /pricing en las páginas de variante, para que el buscador consolide en la URL real.
  • Mantén /ab/* fuera de tu sitemap y ponle noindex.
  • Haz que el SDK devuelva el control para los crawlers (o cuando no haya device_id) — los bots reciben una sola página estable.

Las trampas que cuestan una tarde

  • El control no hace nada. La rama control es NextResponse.next() — sin rewrite, sin página de variante, coste cero en el camino caliente. Esa asimetría es el punto.
  • Fuérzala estática. Marca el control y las páginas de variante como force-static para que se prerrendericen y cacheen; una API dinámica despistada las baja a render por petición.
  • El proxy ahora es Node-first (v16). Mantenlo ligero — una lectura de cookie, una llamada al SDK, un rewrite — para que siga siendo barato allá donde se despliegue.
  • El targeting sale de la petición. Geo del header del CDN (cf-ipcountry), idioma de la ruta, UTMs del query — arma los atributos en el proxy y pásalos al SDK.
  • Planea la salida. Cuando gane una variante, incorpórala a /pricing, y luego borra la ruta de variante y el experimento. Un test A/B dejado para siempre es deuda técnica con cookie.

Conclusiones

  • Una página que es un test A/B puede seguir siendo 100% estática. Lo dinámico es solo cuál página estática — y normalmente es el control, que no requiere trabajo alguno.
  • Resuelve la asignación en el proxy con una plataforma de experimentos según un device id estable — no un random por petición.
  • Reescribe solo para las variantes que no sean el control; el CDN cachea el control y cada variante como su propia página. Sin Vary: Cookie, sin hit al origin.
  • Una cookie de device-id da bucketing persistente y atribución; una cookie-miga corta lleva la variante a la analítica.
  • Protege el SEO con un canonical, noindex en la ruta de variantes y control-para-bots — y desmonta el experimento cuando termine.

¿Ves un error?

¿Un dato incorrecto, una traducción torpe, algo que suena falso en este artículo? Escríbeme — en tu propio idioma.