---
title: A/B Testing for Gutenberg
description: WordPress plugin that adds an A/B Test block to Gutenberg — round-robin variant assignment with no cookies, click and dwell-time tracking, and a per-test analytics dashboard.
tagline: An A/B Test block for the WordPress block editor — cache-friendly, cookie-free, with built-in click + dwell-time analytics.
category: Editor
status: released
version: 4.0.1
date: 2026-05-18
requires:
  php: '8.1'
  wp: '6.7'
license: GPL-2.0-or-later
download:
  filename: ab-testing-gutenberg.zip
screenshot: /plugins/ab-testing-gutenberg/screenshot.png
screenshotAlt: A/B Testing for Gutenberg block sidebar in the WordPress editor
keywords:
  - WordPress A/B testing
  - Gutenberg A/B test block
  - WordPress conversion optimization
  - cookieless A/B testing WordPress
  - WP Rocket compatible A/B test
tags:
  - gutenberg
  - a/b testing
  - analytics
  - wordpress
outcomes:
  - Test any combination of blocks (hero, CTA, pricing, copy) without leaving the editor
  - Works behind WP Rocket / LiteSpeed / object cache — no sessions, no cookies
  - Click-through rate, dwell time, and unique-impression dedup out of the box
  - Per-block or global CSS selectors — no shortcodes, no theme edits
faq:
  - q: Does this plugin use cookies?
    a: "No. Variant assignment is derived from total impression count via a round-robin formula, so it works with full-page caching plugins (WP Rocket, LiteSpeed) without breaking cache."
  - q: How are repeat visitors handled?
    a: "Impressions are deduplicated via SHA-256 hash of `block_id + IP + User-Agent + wp_salt()`. No PII is stored — only the hash. The same visitor counts only once per block."
  - q: Can I run more than two variants?
    a: "Yes. Insert additional A/B Variant child blocks inside the parent A/B Test block. The server-side renderer detects the variant count automatically — no setting to change."
  - q: How accurate is dwell-time tracking?
    a: "Dwell time is measured with `IntersectionObserver` (25% threshold) combined with `visibilitychange` and `pagehide`. Events are sent with `keepalive: true`, so they survive tab close. Sessions under 500 ms are discarded as noise."
  - q: Is the source code open?
    a: "No — the plugin is closed-source. It ships under GPL-2.0-or-later (as required for WordPress code that uses WordPress APIs), but the source is distributed only via the signed download link on this page."
  - q: How do updates work?
    a: "The plugin includes Plugin Update Checker. After installation, it polls a self-hosted update channel on my infrastructure and shows updates in the standard WordPress Plugins screen — no Composer, no FTP."
---

## The problem

Every WordPress site owner eventually hits the same wall: "Does the new hero convert better than the old one?" "Is the green CTA winning or losing against the red one?"

The honest tools to answer that question — Optimizely, VWO, Google Optimize (RIP) — assume you can drop arbitrary JavaScript on the page, mutate the DOM, and rely on cookies to keep a visitor on the same variant. On WordPress with full-page caching turned on, those assumptions break:

- **Cookies and sessions are stripped** by the cache layer before they reach PHP.
- **DOM mutation happens after first paint**, which causes flicker (FOOC) and inflates CLS — a measurable hit to Core Web Vitals.
- **Editors lose visibility.** The marketing person who wants to test a hero variation can't do it from inside the block editor; they have to ticket the dev team and wait.

The plugin solves that specific shape of the problem: **make A/B testing a first-class block in Gutenberg**, with deterministic variant assignment that survives full-page caching, no external JS, and built-in tracking that doesn't require a tag manager.

## How it works

Once activated, the plugin adds an **A/B Test** block to the Gutenberg inserter. The editor flow looks like this:

1. Insert the **A/B Test** parent block on any post or page.
2. Two child **A/B Variant** blocks are auto-created (`Variant A`, `Variant B`).
3. Each variant accepts any nested blocks — headings, images, columns, WooCommerce product grids.
4. In the block sidebar, set the **Click selector** (CSS selector for conversion events) and **Section selector** (for dwell tracking).
5. Save the post.

At runtime, each visitor sees exactly one variant. The plugin records three event types:

| Event | When it fires | What it's used for |
|---|---|---|
| `impression` | Once per visitor per block, deduplicated by session hash | "How many unique visitors saw this variant?" |
| `click` | On any click matching the configured CSS selector inside the visible variant | CTR — the canonical conversion signal |
| `dwell` | After at least 500 ms of cumulative visible time on the section | Engagement quality |

The **A/B Testing → Results** dashboard in `wp-admin` then aggregates per variant: impressions, clicks, CTR, average dwell, sample size.

## Architecture decisions

### 1. Round-robin variant assignment instead of sessions

The single most important design decision. Cookies and `WP_Session` are both incompatible with object-caching and CDN-level full-page caching — both would either skip the cache for every visitor or pin everyone to the first variant rendered.

Instead, the variant index is derived from total impressions:

```php title="src/Database/Repository.php"
public function resolveVariant(string $blockId, int $variants, int $groupSize): int
{
    $count = (int) $this->db->get_var(
        $this->db->prepare(
            "SELECT COUNT(*) FROM {$this->events}
             WHERE block_id = %s AND event_type = 'impression'",
            $blockId
        )
    );
    $cycle = $variants * $groupSize;
    return (int) floor(($count % $cycle) / $groupSize);
}
```

With `variants = 2` and `groupSize = 5`, visitors 1–5 see Variant A, 6–10 see Variant B, 11–15 Variant A again, and so on. Deterministic, atomic, cache-friendly.

The `groupSize` knob exists because pure single-shot round-robin (group size 1) produces a flicker on shared screens — group size 5 is the empirical sweet spot for "looks like the same site to multiple visitors arriving close together."

### 2. Impression dedup without storing PII

To stop the same visitor from counting twice, every impression is keyed by a deterministic session hash — but no identifying info is ever persisted:

```php title="src/Api/TrackingController.php"
private function makeSessionHash(string $blockId, \WP_REST_Request $request): string
{
    $ip = $request->get_header('x-forwarded-for') ?? ($_SERVER['REMOTE_ADDR'] ?? '');
    $ua = $request->get_header('user-agent') ?? '';
    return hash('sha256', $blockId . $ip . $ua . (wp_salt()));
}
```

The salted SHA-256 is what lands in the `session_hash` column. The IP and user agent never touch the database. This is GDPR-friendly out of the box — there's no personal data to delete, because there's no personal data to begin with.

### 3. Dynamic parent block, static variant blocks

The parent block (`ab-testing-gutenberg/ab-test`) uses `render_callback`, so the variant index attribute can be injected server-side, after `wp_kses_post` would otherwise strip it. The variant child blocks (`ab-testing-gutenberg/variant`) save flat HTML — `<div class="abtg-variant">…</div>` — and the parent's renderer walks the saved markup and tags each variant:

```php title="src/Block/ABTestBlock.php"
$variantIndex = 0;
$processed = preg_replace_callback(
    '/<div\b([^>]*\bclass="[^"]*\babtg-variant\b[^"]*"[^>]*)>/',
    function (array $m) use (&$variantIndex): string {
        return '<div' . $m[1] . ' data-variant-index="' . ($variantIndex++) . '">';
    },
    $content
);
$variants = max(1, $variantIndex);
```

This sidesteps two problems at once: `wp_kses_post` keeps stripping unknown `data-*` attributes from saved markup, and visitors who block JS still get a sane fallback (CSS hides all variants until `tracker.js` reveals one).

### 4. Two custom tables, never touch `wp_posts`

Event volume on a busy site can easily hit five-figure rows per day. The plugin uses two purpose-built tables — `wp_abtg_tests` (one row per test) and `wp_abtg_events` (one row per impression / click / dwell). The `wp_postmeta` table is left untouched, which keeps core query performance unaffected and makes uninstall a clean `DROP TABLE`.

### 5. Dwell tracking that survives tab close

Browsers don't reliably fire `unload` anymore. The tracker uses `IntersectionObserver` to measure visible time, then flushes the accumulator on `visibilitychange` and `pagehide` with `fetch(..., { keepalive: true })` — the only combination that is guaranteed to deliver a payload after the page is being torn down.

## Install

1. Download the signed zip from the link at the top of this page.
2. In `wp-admin → Plugins → Add New → Upload Plugin`, choose the zip and click Install Now.
3. Activate. Two database tables are created on activation (`{prefix}abtg_tests`, `{prefix}abtg_events`).
4. Open any post or page in Gutenberg, search the inserter for **A/B Test**, drop the block in.
5. Configure your Click selector (e.g. `.btn-buy`) in the block sidebar.
6. Publish. Results appear under **A/B Testing → Results** in the admin sidebar.

Once installed, the plugin self-checks for updates from a dedicated update server — the same `wp-admin → Plugins` UI you already know.

## Stack

- WordPress 6.7+, PHP 8.1+, `declare(strict_types=1)` everywhere
- `register_block_type` (dynamic parent + static children), `render_callback` injection
- REST API: `POST /wp-json/abtg/v1/event`, `GET /wp-json/abtg/v1/variant`
- Two custom tables created via `dbDelta` on activation
- Vanilla JS tracker (no jQuery, no React on the front end)
- `IntersectionObserver` + `keepalive` fetch for dwell delivery
- `yahnis-elsts/plugin-update-checker` v5 for self-hosted updates
