# @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 `useAbTestAssignments` hook 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

```bash
pnpm add @se-studio/ab-testing
```

## Quick Start

### 1. Implement Your Blob Store

The package requires you to implement the `IBlobStore<AbTest>` interface for your hosting platform.

**Vercel KV Example:**

```typescript
// 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:**

```typescript
// 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

```typescript
// 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).*)'],
};
```

#### Page-test route (static rebuild pattern)

When using `createStaticAbTestMiddleware` with `variantPathPrefix: '/page-test'`, add a `/page-test/[...slugs]` route that rewrites serve variant content on the control URL. Use `findCanonicalPath(testsByPath, variantSlug)` to resolve the user-facing path, then call the paired handlers from `@se-studio/core-ui/server`:

- `generatePageTestMetadata` — control `indexed` and canonical path in `<head>`
- `generatePageTest` — variant body with control identity for breadcrumbs, `href`, and JSON-LD

See the `se-marketing-sites-create-page` skill for the full route template. Direct visits to a variant's own CMS URL (normal page route) should keep using `generatePage`.

### 3. Create the Webhook Handler

```typescript
// 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.

```tsx
// 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):**

```tsx
// 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:**

```tsx
// 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

```typescript
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

```typescript
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

```typescript
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

```typescript
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?: string
```

## Analytics Integration

### Google Tag Manager (GTM)

Push events to `window.dataLayer`:

```typescript
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:
1. Go to Admin > Data display > Custom definitions
2. Create event-scoped dimensions for:
   - `experiment_id`
   - `experiment_name`
   - `variant_id`

## License

MIT
