Introduction
Les tests A/B sont essentiels pour optimiser les produits numériques en mesurant l'impact réel des changements sur des métriques clés comme le taux de conversion ou le temps passé. En 2026, avec Next.js 15 et ses Server Components, les implémentations server-side évitent les biais client-side (adblockers, bots) et garantissent un bucketing déterministe basé sur l'identité utilisateur.
Ce tutoriel expert vous guide pas à pas pour créer un système complet : bucketing hashé, variantes UI conditionnelles, tracking d'événements via Server Actions, stockage en base (Vercel Postgres simulé), et dashboard avec analyse bayésienne pour la significativité. Contrairement aux outils no-code comme Optimizely, cette approche custom est scalable, gratuite et intégrable à votre stack.
Pourquoi c'est crucial ? 70% des tests A/B échouent par manque de rigueur statistique. Ici, on calcule uplift, credible intervals et power analysis pour des résultats publiables. Durée : 20min setup, résultats en live. Prêt à booster vos KPIs de 20%+ ?
Prérequis
- Node.js 20+ et npm/yarn/pnpm
- Next.js 15+ avec App Router et TypeScript
- Connaissances avancées : React Server Components, Server Actions, hooks custom, probabilités (beta distributions)
- Vercel pour déploiement (optionnel, local suffit)
- Outils : VS Code, Git
Initialisation du projet Next.js
npx create-next-app@15 ab-testing-app --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd ab-testing-app
npm install recharts uuid
npm install -D @types/uuid
npm run devCe script crée un projet Next.js 15 minimal avec TypeScript, Tailwind pour UI rapide, et Recharts pour le dashboard. UUID servira au bucketing déterministe. Lancez npm run dev pour tester sur http://localhost:3000. Piège : Vérifiez la version Next.js 15 pour Server Actions natives.
Principes du bucketing server-side
Le bucketing assigne un utilisateur à une variante (A ou B) de façon déterministe : hash(userId) % 2. Server-side évite les incohérences (refresh page). On utilise un cookie 'ab-variant' pour persistance. Pour l'expert : ratio 50/50, mais extensible à multi-variantes ou MVT.
Utilitaire de bucketing déterministe
import { cookies } from 'next/headers';
import { v5 as uuidv5 } from 'uuid';
import { randomUUID } from 'crypto';
export const VARIANTS = ['control', 'variant'] as const;
export type Variant = typeof VARIANTS[number];
export function getUserId() {
const cookieStore = cookies();
let userId = cookieStore.get('userId')?.value;
if (!userId) {
userId = randomUUID();
cookieStore.set('userId', userId, { maxAge: 365 * 24 * 60 * 60 });
}
return userId;
}
export function assignVariant(experimentId: string): Variant {
const userId = getUserId();
const hash = uuidv5(userId + experimentId, uuidv5.DNS);
const index = parseInt(hash.slice(0, 8), 16) % VARIANTS.length;
return VARIANTS[index];
}
export function getVariant(experimentId: string): Variant {
const cookieStore = cookies();
let variant = cookieStore.get('ab-variant')?.value as Variant;
if (!variant) {
variant = assignVariant(experimentId);
cookieStore.set('ab-variant', variant, { maxAge: 365 * 24 * 60 * 60 });
}
return variant;
}Cet utilitaire génère un userId unique (UUID v4), puis assigne la variante via hash UUID v5 (déterministe, collision-proof). Server-only via cookies(). Avantage : sticky sur devices. Piège : Utilisez toujours getVariant dans RSC, pas assignVariant seul pour éviter re-assignment.
Implémentation de la page de test A/B
Analogie : Comme un pont A/B où seuls 50% passent par chaque voie, mesurée indépendamment. On rend la variante server-side pour SEO et perf (pas de JS hydration leak).
Page principale avec variantes A/B
import { getVariant } from '@/lib/bucketing';
export default function Home() {
const variant = getVariant('hero-cta-test');
return (
<main className="flex min-h-screen flex-col items-center justify-center p-24">
<h1 className="text-4xl font-bold mb-8">Test A/B Hero CTA</h1>
<div className="max-w-md mx-auto">
{variant === 'control' ? (
<div className="bg-blue-500 text-white p-8 rounded-lg shadow-lg">
<p className="text-xl mb-4">Version A : Bouton standard</p>
<button
className="bg-white text-blue-500 px-6 py-3 rounded font-semibold w-full"
onClick={() => window.trackConversion?.('control_click')}
>
S'inscrire gratuitement
</button>
</div>
) : (
<div className="bg-green-500 text-white p-8 rounded-lg shadow-lg">
<p className="text-xl mb-4">Version B : Bouton urgent</p>
<button
className="bg-orange-400 text-white px-6 py-3 rounded font-bold text-lg w-full uppercase tracking-wide"
onClick={() => window.trackConversion?.('variant_click')}
>
Offre limitée : Inscrivez-vous !
</button>
</div>
)}
<p className="text-sm mt-4 text-gray-500">Votre variante : <strong>{variant}</strong></p>
</div>
</main>
);
}Server Component fetch la variante via getVariant, rendant HTML statique par bucket. Boutons trackent clics via global window.trackConversion (hooké plus tard). Perf : zéro JS pour variante. Piège : onClick client-only, hydratez si besoin avec 'use client'.
Tracking et stockage des métriques
On tracke impressions et conversions via un hook client + Server Action pour log en DB. Simulé ici avec in-memory (remplacez par Vercel Postgres/Supabase). Expert : atomic increments pour concurrency.
Hook client pour tracking
import { useEffect, useCallback } from 'react';
interface TrackEvent {
experiment: string;
variant: string;
event: 'impression' | 'conversion';
}
let isTrackingLoaded = false;
export function useABTracking(experiment: string, variant: string) {
useEffect(() => {
if (!isTrackingLoaded) {
(window as any).trackConversion = async (eventType: string) => {
await fetch('/api/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ experiment, variant, event: eventType as TrackEvent['event'] }),
});
};
isTrackingLoaded = true;
}
}, [experiment, variant]);
useEffect(() => {
// Track impression
(window as any).trackConversion?.('impression');
}, []);
}Hook charge le tracker global une fois, tracke impressions auto et conversions onClick. POST vers API route pour server-log (anti-tampering). Piège : useCallback non requis ici, mais scalez avec debounce pour bursts.
API route pour logger les métriques
import { NextRequest, NextResponse } from 'next/server';
// In-memory store (use DB en prod)
const metrics: Record<string, { impressions: number; conversions: number }> = {
'hero-cta-test_control': { impressions: 0, conversions: 0 },
'hero-cta-test_variant': { impressions: 0, conversions: 0 },
};
export async function POST(request: NextRequest) {
const { experiment, variant, event } = await request.json();
const key = `${experiment}_${variant}`;
if (!metrics[key]) {
metrics[key] = { impressions: 0, conversions: 0 };
}
if (event === 'impression') {
metrics[key].impressions++;
} else if (event === 'conversion') {
metrics[key].conversions++;
}
return NextResponse.json({ success: true });
}
export const dynamic = 'force-dynamic';API route POST incrémente métriques in-memory (reset sur restart ; utilisez Redis/Postgres). Clé unique par experiment+variant. force-dynamic pour real-time. Piège : Pas de validation ; ajoutez Zod en prod.
Dashboard et analyse bayésienne
Bayésien vs fréquentiste : Credible intervals > p-values. On utilise Beta prior (uniforme) pour uplift postérieur.
Dashboard avec stats bayésiennes
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts';
// Mock metrics (fetch from API/DB)
const metrics = {
control: { impressions: 1000, conversions: 50 },
variant: { impressions: 1000, conversions: 70 },
};
function betaPDF(x: number, alpha: number, beta: number): number {
return Math.pow(x, alpha - 1) * Math.pow(1 - x, beta - 1) / Math.exp(Math.lgamma(alpha + beta) - Math.lgamma(alpha) - Math.lgamma(beta));
}
function sampleBeta(alpha: number, beta: number, samples = 10000): number[] {
const samplesArr: number[] = [];
for (let i = 0; i < samples; i++) {
let y = Math.random();
let u = Math.random();
let v = Math.log(1 - y) / Math.log(Math.random());
if (Math.log(u) < alpha * Math.log(y / (1 - y)) + beta * v) {
samplesArr.push(y);
} else {
i--; // Rejection sampling
}
}
return samplesArr;
}
export default function Dashboard() {
const controlCR = metrics.control.conversions / metrics.control.impressions;
const variantCR = metrics.variant.conversions / metrics.variant.impressions;
const uplift = ((variantCR - controlCR) / controlCR) * 100;
const controlAlpha = 1 + metrics.control.conversions;
const controlBeta = 1 + (metrics.control.impressions - metrics.control.conversions);
const variantAlpha = 1 + metrics.variant.conversions;
const variantBeta = 1 + (metrics.variant.impressions - metrics.variant.conversions);
const controlSamples = sampleBeta(controlAlpha, controlBeta);
const variantSamples = sampleBeta(variantAlpha, variantBeta);
const upliftSamples = variantSamples.map((vs, i) => ((vs - controlSamples[i]) / controlSamples[i]) * 100);
const ciLower = upliftSamples.sort((a, b) => a - b)[Math.floor(0.025 * upliftSamples.length)];
const ciUpper = upliftSamples.sort((a, b) => a - b)[Math.floor(0.975 * upliftSamples.length)];
const data = [
{ name: 'Control', impressions: metrics.control.impressions, conversions: metrics.control.conversions },
{ name: 'Variant', impressions: metrics.variant.impressions, conversions: metrics.variant.conversions },
];
return (
<div className="p-8 max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Dashboard Test A/B</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<div>
<h2>Conversion Rates</h2>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data}>
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="conversions" fill="#8884d8" name="Conversions" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="bg-gray-100 p-6 rounded-lg">
<h2 className="text-2xl mb-4">Analyse Bayésienne</h2>
<p>Uplift moyen : <strong>{uplift.toFixed(1)}%</strong></p>
<p>Intervalle crédible 95% : [{ciLower.toFixed(1)}%, {ciUpper.toFixed(1)}%]</p>
<p className={ciLower > 0 ? 'text-green-600' : 'text-red-600'}>
{ciLower > 0 ? '✅ Significatif : Variant supérieur' : '❌ Pas de différence significative'}
</p>
</div>
</div>
);
}Dashboard fetch métriques (hardcodé ; fetch API), calcule CR/uplift, sample Beta postérieurs via rejection sampling pour CI. Recharts pour viz. Expert : Uniform prior (alpha=1,beta=1). Piège : Math.lgamma polyfill si Node <18 ; scalez samples pour précision.
Bonnes pratiques
- Power analysis upfront : Calculez sample size requis (e.g., 1000+ par bras pour 10% MDE à 80% power).
- Sequential testing : Monitorez avec alpha-spending (pas peeking fixe).
- Segmentation : Ajoutez user props (geo, device) au bucketing.
- Multi-arm : Étendez VARIANTS à N, guardez FDR avec Bonferroni.
- Prod : Migrez vers Postgres + Redis ; intégrez Amplitude/PostHog.
Erreurs courantes à éviter
- Priming bias : Ne testez pas sur traffic existant sans baseline ; randomisez tout.
- Sample pollution : Évitez novelty effect avec holdout groups.
- P-hacking : Fixez thresholds avant test ; ignorez p-values post-hoc.
- JS-only : 20-30% users bloquent trackers → server-side impératif.
Pour aller plus loin
Approfondissez avec nos formations expertes sur l'optimisation produit. Ressources : 'Trustworthy Online Controlled Experiments' (Kohavi), GrowthBook OSS, Vercel Analytics SDK. Déployez sur Vercel pour A/B edge-side.