A/B testing con Claude Code para SaaS y monetización de blogs
Diseña tests A/B con Claude Code: hipótesis, eventos, asignación en servidor, SQL, privacidad, guardrails y rollback.
Empieza por la hipótesis, no por el botón
Un test A/B no consiste en enseñar dos pantallas al azar. Para un SaaS o un blog monetizado, sirve para responder una pregunta de negocio: si un cambio mejora registros, intención de compra, clics de afiliado, ingresos publicitarios o reservas sin romper el resto del embudo. Claude Code puede escribir el cambio visual muy rápido, pero un experimento útil necesita hipótesis, asignación estable, eventos medibles, tamaño de muestra, métricas de seguridad, privacidad y una forma clara de volver atrás.
Traducido a términos simples: una variante es la versión que se compara; una exposición es el momento en que un usuario ve una variante y queda registrado; un guardrail es una métrica que no debe empeorar, como LCP, errores JavaScript o clics de intención de compra; un falso positivo es creer que una mejora es real cuando solo fue ruido estadístico.
El primer prompt para Claude Code debe contener la pregunta de negocio:
Diseña un flujo de A/B testing para un SaaS/blog en Next.js App Router.
El objetivo es monetización, no clics de vanidad.
experiment id: pricing_page_offer_2026_06
hipótesis: cambiar el CTA de pricing de "Start free trial" a "Start with the free plan" aumentará el inicio de registros sin reducir clics de intención de compra.
métrica principal: signup_start_rate
guardrails: purchase_link_click_rate, p75 LCP, JavaScript error rate
entregables: schema de eventos, asignación en servidor, riesgos de Cookie/localStorage, SQL tipo BigQuery, verificación con Playwright y checklist de rollout/rollback.
Usa casos concretos. En SaaS puedes probar el texto del CTA de pricing, el número de pasos de onboarding o el orden de los planes. En un blog puedes probar la posición de un bloque de afiliado, el texto de una newsletter o una caja de consultoría. En un embudo de productos puedes probar si el lead magnet aparece antes o después de la recomendación pagada. Para la base de flags, revisa feature flags con Claude Code; para medición, analítica con Claude Code.
| Caso | Métrica principal | Guardrails | Error frecuente |
|---|---|---|---|
| CTA de pricing SaaS | Inicio de registro | Clics de compra, errores, LCP | Más registros pero menor calidad comercial |
| Bloque de afiliados en blog | Clics al producto | Lectura completa, rebote, velocidad | El bloque aparece demasiado pronto y reduce confianza |
| Formulario newsletter | Suscripciones completadas | Spam, bajas | La lista crece pero empeora la calidad |
| Onboarding | Primer éxito | Tickets, activación real | La finalización inicial oculta churn posterior |
Congela el schema de eventos antes de tocar la UI
El fallo más caro aparece después del lanzamiento: los datos no se pueden unir. Si el mismo clic se llama button_click, ctaClicked y signup_click, el análisis se convierte en limpieza manual. Pide a Claude Code un contrato de eventos tipado antes de pedir componentes. Si usas Google Analytics, consulta la referencia oficial de eventos GA4 y la referencia de parámetros de Google tag.
// lib/experiment-events.ts
export type ExperimentId = "pricing_page_offer_2026_06";
export type VariantId = "control" | "free_plan_copy";
export type ExperimentEvent =
| {
event_name: "experiment_exposure";
experiment_id: ExperimentId;
variant: VariantId;
anonymous_id: string;
page_path: string;
}
| {
event_name: "cta_click";
experiment_id: ExperimentId;
variant: VariantId;
anonymous_id: string;
cta_id: "pricing_primary" | "article_bottom" | "sidebar_offer";
page_path: string;
}
| {
event_name: "purchase_link_click";
experiment_id: ExperimentId;
variant: VariantId;
anonymous_id: string;
product_id: string;
value_usd: number;
page_path: string;
}
| {
event_name: "guardrail_metric";
experiment_id: ExperimentId;
variant: VariantId;
anonymous_id: string;
metric_name: "lcp_ms" | "js_error" | "bounce";
value: number;
page_path: string;
};
declare global {
interface Window {
gtag?: (command: "event", name: string, params: Record<string, unknown>) => void;
}
}
export function trackExperimentEvent(event: ExperimentEvent) {
if (typeof window === "undefined") return;
window.gtag?.("event", event.event_name, {
experiment_id: event.experiment_id,
variant: event.variant,
anonymous_id: event.anonymous_id,
page_path: event.page_path,
...event,
});
}
No envíes emails, nombres, empresas ni texto libre en los eventos. Si tu región exige consentimiento para analítica o publicidad, inicializa el estado antes de enviar tags. Google lo documenta en la guía oficial de consent mode.
Asigna variantes en el servidor
localStorage parece cómodo, pero genera parpadeo en el primer render, cambios entre usuario anónimo y login, reinicios en modo privado y problemas cuando el navegador bloquea almacenamiento. MDN describe localStorage como almacenamiento por origen que persiste entre sesiones, pero eso no lo convierte en una buena fuente de verdad para el primer render. Referencia: MDN localStorage.
En Next.js App Router, un Route Handler es un punto de partida práctico. La documentación oficial de route.ts explica cómo crear handlers con Web Request/Response APIs. Para cookies, usa NextResponse. Si necesitas lógica en el borde, ten en cuenta que Next.js 16 renombró Middleware a Proxy; consulta proxy.js.
// app/api/experiments/assign/route.ts
import { NextRequest, NextResponse } from "next/server";
export const runtime = "edge";
type Variant = "control" | "free_plan_copy";
const EXPERIMENTS = {
pricing_page_offer_2026_06: {
cookieName: "ab_pricing_page_offer_2026_06",
variants: [
{ id: "control", weight: 50 },
{ id: "free_plan_copy", weight: 50 },
] satisfies Array<{ id: Variant; weight: number }>,
},
};
function hashToBucket(input: string) {
let hash = 2166136261;
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return Math.abs(hash) % 100;
}
function chooseVariant(experimentId: keyof typeof EXPERIMENTS, anonymousId: string): Variant {
const experiment = EXPERIMENTS[experimentId];
const bucket = hashToBucket(`${experimentId}:${anonymousId}`);
let cumulative = 0;
for (const variant of experiment.variants) {
cumulative += variant.weight;
if (bucket < cumulative) return variant.id;
}
return experiment.variants[0].id;
}
export async function GET(request: NextRequest) {
const experimentId = request.nextUrl.searchParams.get("experiment");
if (experimentId !== "pricing_page_offer_2026_06") {
return NextResponse.json({ error: "Unknown experiment" }, { status: 404 });
}
const experiment = EXPERIMENTS[experimentId];
const testAnonymousId = request.headers.get("x-test-anonymous-id");
const existingCookie = request.cookies.get(experiment.cookieName)?.value;
const anonymousId = testAnonymousId ?? existingCookie ?? crypto.randomUUID();
const variant = chooseVariant(experimentId, anonymousId);
const response = NextResponse.json({
experimentId,
variant,
anonymousId,
});
response.cookies.set(experiment.cookieName, anonymousId, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 24 * 30,
});
return response;
}
Las cookies también tienen límites. La guía de MDN sobre configuración segura de cookies cubre Secure, HttpOnly y SameSite. En SaaS puedes usar un id de usuario hasheado tras login; en un blog público, una cookie anónima con caducidad corta; en publicidad, el estado del CMP debe mandar.
Separa experimento, rollout y rollback
El código del experimento puede estar desplegado, pero la exposición debe poder cambiar sin redeploy. Vercel ofrece Vercel Flags; para empezar, una configuración explícita también sirve.
# config/experiments.yaml
experiments:
pricing_page_offer_2026_06:
status: running
owner: masa
hypothesis: "Free-plan copy increases signup starts without hurting paid intent."
allocation_percent: 50
variants:
control: 50
free_plan_copy: 50
primary_metric: signup_start_rate
guardrails:
- purchase_link_click_rate
- p75_lcp_ms
- js_error_rate
rollback:
if_js_error_rate_increases_by: 0.02
if_p75_lcp_ms_worse_by_ms: 300
action: "set allocation_percent to 0 and keep logging exposure for audit"
La regla de rollback evita discusiones cuando el equipo se enamora de la versión nueva. Si suben los errores, empeora LCP o cae la intención de compra, corta exposición y conserva datos para auditoría. Después escala de 10% a 50% y 100%.
Analiza desde la exposición y evita falsos positivos
El denominador debe ser la exposición. Un usuario que nunca vio la variante no pertenece al experimento. Si un usuario vio dos variantes, exclúyelo o investiga. En BigQuery, SAFE_DIVIDE evita fallos por división entre cero.
-- BigQuery Standard SQL
WITH exposure_raw AS (
SELECT
anonymous_id,
experiment_id,
ARRAY_AGG(variant ORDER BY event_timestamp LIMIT 1)[OFFSET(0)] AS variant,
MIN(event_timestamp) AS first_exposed_at,
COUNT(DISTINCT variant) AS variant_count
FROM `project.dataset.events`
WHERE event_name = 'experiment_exposure'
AND experiment_id = 'pricing_page_offer_2026_06'
GROUP BY anonymous_id, experiment_id
),
exposure AS (
SELECT anonymous_id, experiment_id, variant, first_exposed_at
FROM exposure_raw
WHERE variant_count = 1
),
events_after_exposure AS (
SELECT
e.variant,
e.anonymous_id,
ev.event_name,
ev.value_usd,
ev.value_ms
FROM exposure e
LEFT JOIN `project.dataset.events` ev
ON ev.anonymous_id = e.anonymous_id
AND ev.experiment_id = e.experiment_id
AND ev.event_timestamp >= e.first_exposed_at
)
SELECT
variant,
COUNT(DISTINCT anonymous_id) AS exposed_users,
COUNT(DISTINCT IF(event_name = 'cta_click', anonymous_id, NULL)) AS cta_users,
SAFE_DIVIDE(
COUNT(DISTINCT IF(event_name = 'cta_click', anonymous_id, NULL)),
COUNT(DISTINCT anonymous_id)
) AS cta_click_rate,
COUNT(DISTINCT IF(event_name = 'purchase_link_click', anonymous_id, NULL)) AS purchase_intent_users,
SAFE_DIVIDE(
COUNT(DISTINCT IF(event_name = 'purchase_link_click', anonymous_id, NULL)),
COUNT(DISTINCT anonymous_id)
) AS purchase_intent_rate,
AVG(IF(event_name = 'guardrail_metric' AND value_ms IS NOT NULL, value_ms, NULL)) AS avg_guardrail_ms,
SUM(IF(event_name = 'guardrail_metric' AND value_usd IS NOT NULL, value_usd, 0)) AS revenue_proxy_usd
FROM events_after_exposure
GROUP BY variant
ORDER BY variant;
Define tamaño de muestra y ventana de observación antes del lanzamiento. Mirar el resultado cada día y parar cuando la versión nueva va arriba aumenta falsos positivos. También contaminan el resultado las campañas pagadas simultáneas, demasiados segmentos y cambiar la métrica principal a mitad del experimento.
Verifica con Playwright
Antes de publicar, comprueba que el mismo usuario recibe la misma variante, que un experimento desconocido falla y que el CTA de monetización aparece una sola vez. Playwright documenta test y expect, además de assertions con reintento automático.
// tests/experiments.spec.ts
import { test, expect } from "@playwright/test";
test.describe("pricing_page_offer_2026_06", () => {
test("keeps assignment stable for the same anonymous id", async ({ request, baseURL }) => {
const url = `${baseURL}/api/experiments/assign?experiment=pricing_page_offer_2026_06`;
const headers = { "x-test-anonymous-id": "demo-user-42" };
const first = await request.get(url, { headers });
const second = await request.get(url, { headers });
expect(first.ok()).toBeTruthy();
expect(second.ok()).toBeTruthy();
expect(await first.json()).toMatchObject(await second.json());
});
test("rejects unknown experiments", async ({ request, baseURL }) => {
const response = await request.get(`${baseURL}/api/experiments/assign?experiment=missing`);
expect(response.status()).toBe(404);
});
test("renders one monetization CTA on the pricing page", async ({ page }) => {
await page.goto("/pricing?e2e_anonymous_id=demo-user-42");
await expect(page.getByTestId("pricing-cta")).toBeVisible();
await expect(page.getByTestId("pricing-cta")).toHaveCount(1);
});
});
En la práctica de Masa, el mayor avance no fue el SQL, sino escribir primero la tabla de eventos. Decidir qué clic cuenta como CTA de monetización, cuándo registrar exposición y qué hacer con usuarios en dos variantes redujo mucho el retrabajo de Claude Code. En una prueba pequeña, Playwright detectó un parpadeo causado por localStorage, así que la asignación volvió al servidor con cookie.
Para llevar esto a un embudo real, revisa Claude Code training y product templates. El objetivo no es lanzar más experimentos, sino que cada uno responda una pregunta de negocio sin dañar la confianza del usuario.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Escalera de permisos de Claude Code para ampliar acceso sin perder control
Pasa de read-only a ediciones limitadas, comandos de prueba y checks de deploy con menos riesgo.
Claude Code Small PR Proof Pack: cambios pequeños que sí se pueden revisar
Un paquete de prueba para PRs de Claude Code: diff, checks, URL pública, CTA y rollback.
Gate de revisión antes del commit con Claude Code
Cómo revisar con Claude Code antes del commit: diff, build, URL pública, Gumroad, consultoría, tests y archivos ajenos.