@se-studio/ab-testing
v1.0.0
Published
Server-side A/B testing framework for Next.js applications with Contentful CMS
Maintainers
Readme
@se-studio/ab-testing
Server-side A/B testing framework for Next.js applications with Contentful CMS.
Features
- Server-side rendering: No flicker or layout shift - variants are rendered on the server
- Edge-optimized: Middleware runs at the edge for minimal latency
- CMS-driven: Test configuration managed entirely in Contentful
- Provider-agnostic: Bring your own blob storage (Vercel KV, Netlify Blobs, etc.)
- Flexible analytics: Use the
useAbTestAssignmentshook to integrate with any analytics platform
Architecture
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Contentful │────▶│ Webhook Handler │────▶│ Blob Store │
│ PageTest │ │ (API Route) │ │ (Project- │
│ Entries │ │ │ │ specific) │
└─────────────────┘ └──────────────────┘ └────────┬────────┘
│
▼
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ User Request │────▶│ Middleware │────▶│ Variant Page │
│ /pricing │ │ (Edge) │ │ (SSR) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│
▼
┌──────────────────┐
│ Cookie Set │
│ ab-test-info │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ useAbTestAssign- │
│ ments Hook │
│ → Your Analytics │
└──────────────────┘Installation
pnpm add @se-studio/ab-testingQuick Start
1. Implement Your Blob Store
The package requires you to implement the IBlobStore<AbTest> interface for your hosting platform.
Vercel KV Example:
// src/server/abTestStore.ts
import { kv } from '@vercel/kv';
import type { IBlobStore, AbTest } from '@se-studio/ab-testing';
const STORE_KEY = 'ab-tests';
export function getAbTestStore(): IBlobStore<AbTest> {
return {
async get(key) {
const data = await kv.hget<AbTest>(STORE_KEY, key);
return data ?? undefined;
},
async set(key, value) {
await kv.hset(STORE_KEY, { [key]: value });
},
async bulkWrite(entries) {
await kv.del(STORE_KEY);
if (entries.length > 0) {
const data = Object.fromEntries(entries);
await kv.hset(STORE_KEY, data);
}
},
async size() {
return kv.hlen(STORE_KEY);
},
async values() {
const data = await kv.hgetall<Record<string, AbTest>>(STORE_KEY);
return data ? Object.values(data) : [];
},
};
}Netlify Blobs Example:
// src/server/abTestStore.ts
import { getStore } from '@netlify/blobs';
import type { IBlobStore, AbTest } from '@se-studio/ab-testing';
export function getAbTestStore(): IBlobStore<AbTest> {
const store = getStore('ab-testing');
const BLOB_NAME = 'config';
return {
async get(key) {
const data = await store.get(BLOB_NAME, { type: 'json' });
return data?.[key];
},
async set(key, value) {
const data = (await store.get(BLOB_NAME, { type: 'json' })) ?? {};
data[key] = value;
await store.setJSON(BLOB_NAME, data);
},
async bulkWrite(entries) {
const data = Object.fromEntries(entries);
await store.setJSON(BLOB_NAME, data);
},
async size() {
const data = await store.get(BLOB_NAME, { type: 'json' });
return data ? Object.keys(data).length : 0;
},
async values() {
const data = await store.get(BLOB_NAME, { type: 'json' });
return data ? Object.values(data) : [];
},
};
}2. Set Up the Middleware
// src/middleware.ts
import { createAbTestMiddleware } from '@se-studio/ab-testing/middleware';
import { getAbTestStore } from './server/abTestStore';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const abTestHandler = createAbTestMiddleware({
getStore: getAbTestStore,
cacheTtlMs: 60000, // 60 second cache
});
export async function middleware(request: NextRequest) {
// Skip static assets, API routes, etc.
if (request.nextUrl.pathname.startsWith('/_next') ||
request.nextUrl.pathname.startsWith('/api')) {
return NextResponse.next();
}
// Process A/B tests
const abResponse = await abTestHandler(request);
if (abResponse) return abResponse;
return NextResponse.next();
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};3. Create the Webhook Handler
// src/app/api/webhooks/ab-test/route.ts
import { createWebhookHandler } from '@se-studio/ab-testing/webhook';
import { getAbTestStore } from '@/server/abTestStore';
import { revalidateTag } from 'next/cache';
// Your Contentful fetch function
async function fetchPageTests() {
// Fetch all PageTest entries from Contentful
// Return array of RawPageTest objects
}
export const dynamic = 'force-dynamic';
export const POST = createWebhookHandler({
fetchPageTests,
getStore: getAbTestStore,
webhookSecret: process.env.CONTENTFUL_WEBHOOK_SECRET,
revalidate: () => revalidateTag('PageTests'),
});4. Create Your Analytics Reporter (Project-Specific)
The package provides a useAbTestAssignments hook that returns test assignments for the current page. You create your own reporter component with your project's specific analytics integrations.
// src/components/AbTestReporter.tsx (project-specific)
'use client';
import { useEffect } from 'react';
import { useAbTestAssignments } from '@se-studio/ab-testing';
import { sendEvent } from '@/lib/analytics';
export function AbTestReporter() {
const assignments = useAbTestAssignments();
useEffect(() => {
for (const assignment of assignments) {
// Send to GTM/GA4
sendEvent('experiment_impression', {
experiment_id: assignment.testId,
experiment_name: assignment.test_label,
variant_id: assignment.test_path,
});
}
}, [assignments]);
return null;
}With HubSpot Integration (HSD-style):
// src/components/AbTestReporter.tsx
'use client';
import { useEffect } from 'react';
import { useAbTestAssignments } from '@se-studio/ab-testing';
import { sendEvent } from '@/lib/analytics';
import { sendHubspotCustomEvent } from '@/lib/hubspotCustomEvents';
export function AbTestReporter() {
const assignments = useAbTestAssignments();
useEffect(() => {
for (const assignment of assignments) {
// Send to GTM/GA4
sendEvent('experiment_impression', {
experiment_id: assignment.testId,
experiment_name: assignment.test_label,
variant_id: assignment.test_path,
});
// Send to HubSpot (if configured)
if (assignment.hubspot_event) {
sendHubspotCustomEvent(assignment.hubspot_event, {
experiment_name: assignment.test_label,
experiment_id: assignment.testId,
});
}
}
}, [assignments]);
return null;
}Add to your layout:
// src/app/layout.tsx
import { AbTestReporter } from '@/components/AbTestReporter';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
<AbTestReporter />
</body>
</html>
);
}Contentful Content Model
PageTest
| Field | Type | Description |
|-------|------|-------------|
| cmsLabel | Symbol | Internal label for the test |
| control | Reference (Page/PageVariant) | The control page to test against |
| enabled | Boolean | Whether the test is active |
| trackingLabel | Symbol (optional) | Override for analytics label |
| searchParameters | Symbol (optional) | URL params to match (e.g., "utm_source=google") |
| configuration | JSON | Array of {weight, hubspot_event_name?} |
| variants | References (PageVariant[]) | Variants to test |
PageVariant
Existing content type that references an original page and defines component swaps.
API Reference
Types
interface AbTest {
id: string;
cmsLabel: string;
controlSlug: string;
searchParameters?: string;
trackingLabel?: string;
enabled: boolean;
configuration: AbTestVariantConfig[];
variants: AbTestVariant[];
}
interface IBlobStore<T> {
get(key: string): Promise<T | undefined>;
set(key: string, value: T): Promise<void>;
bulkWrite(entries: [string, T][]): Promise<void>;
size(): Promise<number>;
values(): Promise<T[]>;
}
interface ActiveAbTestAssignment {
testId: string;
test_label: string;
test_path: string;
hubspot_event?: string;
original_path?: string;
}Middleware
import { createAbTestMiddleware } from '@se-studio/ab-testing/middleware';
const handler = createAbTestMiddleware({
getStore: () => store, // Required: blob store factory
cacheTtlMs: 60000, // Optional: cache TTL (default: 60s)
cookieName: 'ab-test-info', // Optional: cookie name
cookieMaxAge: 2592000, // Optional: cookie max age (default: 30 days)
shouldProcess: (path) => true, // Optional: filter requests
devTestData: [], // Optional: test data for development
});Webhook
import { createWebhookHandler } from '@se-studio/ab-testing/webhook';
export const POST = createWebhookHandler({
fetchPageTests: () => Promise<RawPageTest[]>, // Required
getStore: () => store, // Required
webhookSecret: 'secret', // Optional
revalidate: () => void, // Optional
onSkippedTest: (id, reason) => void, // Optional
});Hook
import { useAbTestAssignments } from '@se-studio/ab-testing';
// Returns array of assignments for the current page
const assignments = useAbTestAssignments({
cookieName: 'ab-test-info', // Optional: custom cookie name
});
// Each assignment contains:
// - testId: string
// - test_label: string
// - test_path: string (variant slug or "control")
// - hubspot_event?: string
// - original_path?: stringAnalytics Integration
Google Tag Manager (GTM)
Push events to window.dataLayer:
window.dataLayer.push({
event: 'experiment_impression',
experiment_id: assignment.testId,
experiment_name: assignment.test_label,
variant_id: assignment.test_path,
});Google Analytics 4 (GA4)
Register custom dimensions in GA4:
- Go to Admin > Data display > Custom definitions
- Create event-scoped dimensions for:
experiment_idexperiment_namevariant_id
License
MIT
