← Oleksii Turovskyi
ReleasedEditorv4.0.1GPL-2.0-or-later

A/B Testing for Gutenberg

An A/B Test block for the WordPress block editor — cache-friendly, cookie-free, with built-in click + dwell-time analytics.

  • 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
WordPress
6.7+
PHP
8.1+
Dependencies
None

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:

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:

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:

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

FAQ

Does this plugin use cookies?
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.
How are repeat visitors handled?
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.
Can I run more than two variants?
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.
How accurate is dwell-time tracking?
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.
Is the source code open?
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.
How do updates work?
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.

View as Markdown · for AI agents and offline reading

Other plugins

Other tools

Not a WordPress site? I also ship Chrome extensions — small browser utilities for SEO, AI workflows, and research.