A WordPress A/B test needs both PostHog and code on the page. PostHog assigns visitors to variants and analyzes the result. (+) The page code shows each visitor their assigned version. It also needs to leave the page usable if a flag is late and keep visitors out of the test when the required elements are missing.
We build and preview the variants on a copy of the WordPress page, move the tested changes to the live page, and then launch the experiment in PostHog. Use our PostHog experiment guide for metric planning, targeting and reading results.
The example uses Divi. You can use another builder if you can give the elements stable selectors and add JavaScript to the tested page.
What You Need Before You Start
Install PostHog first using the WordPress installation guide. Before you build the experiment, make sure these pieces are in place:
- A working PostHog project.
- Access to the WordPress page and permission to edit its modules, CSS, and JavaScript.
- A clear test hypothesis and the exact section or module you want to change.
- A conversion event in PostHog recorded after a confirmed business action, such as a successful form submission.
The browser example assumes an initialized global window.posthog client. If your WordPress build uses JavaScript modules instead, install @99ways/[email protected], import runExperiment, and pass your initialized client through its posthog option. (+)
For a small text, CSS, or HTML edit on a server-rendered page, PostHog’s no-code web experiments are a simpler alternative. Set disable_web_experiments: false in your PostHog initialization and build the variants in the toolbar. Those edits can be overwritten if the page re-renders. Use the runner when you need coordinated module changes, eligibility checks, or repeatable URL previews; its limits on re-rendered content are covered in Step 6.
Decide What You Are Testing
Decide what you want to learn before duplicating modules.
For example:
- Does a clearer hero headline increase successful form submissions?
- Does a shorter CTA section increase button clicks?
- Does changing the webinar framing improve opt-ins?
- Does replacing a pricing block reduce drop-off?
Keep the first test focused. If the headline, CTA, layout and pricing copy all change, the result tells you how that version performed as a whole. It does not tell you which edit made the difference.
Start with one control and one test variant unless you have enough traffic for more. More variants split the traffic and usually require more time to reach a useful result. (+) Estimate the traffic needed for the smallest improvement worth detecting before adding another variant.
Write down:
- The page being tested.
- The control version.
- The test variant or variants.
- The primary conversion event.
- The expected business outcome.
For example:
We are testing whether a more specific above-the-fold headline increases successful lead-form submissions on the About page.
Open our working experiment test page beside your own page. It uses the same 0.2.0 runner on the current Astro site, rather than WordPress. Each of its three headings names the group being displayed.
Preview its control, Test group 1, and Test group 2. The demo’s flag key contains a capital H in Heading; copy it exactly for those links. The WordPress example below uses a lowercase key.
This setup changes modules at one URL. A full-page test at separate URLs needs redirect logic that preserves the visitor’s identity and temporary redirects and canonical tags for the test URLs. (+ / +) Duplicating a page alone does not implement that routing.
Step 1: Duplicate the WordPress Page
In WordPress, duplicate the page you want to test before editing anything.
- Go to Pages in the WordPress dashboard.
- Find the page you want to test.
- Duplicate it using your normal page duplication tool. With Yoast Duplicate Post, click New Draft below the page title.
- Rename the copied page so it is easy to identify, such as
About Page - PostHog Test. - Keep the copied page unpublished, password-protected, or otherwise hidden until QA is complete.
Build and review the variants on this copy before changing the live page.
Step 2: Create the Experiment in PostHog
PostHog experiments use feature flags to assign variants. That feature flag is what your WordPress page will read to decide which module to show.
In PostHog:
- Open Experiments and click New experiment.
- In Description, add the experiment name, hypothesis, and feature flag key.
- In Variant rollout, add your control and test variants and confirm the traffic split.
- In Analytics, set the inclusion criteria.
- Click Save as draft.
Keep the experiment’s flag disabled during draft QA. Enabling the feature flag can serve variants before the experiment launches; if you need that for testing, restrict its release conditions to your team first. (+)
Use our experiment naming format so the name, flag, and variants stay connected:
[Experiment number] - [Funnel step or page] - [Test hypothesis]
Example:
001 - About page - Heading clarity test
For the feature flag key, keep the experiment number and page, use lowercase, remove spaces, and separate words with hyphens. You can abbreviate the page name consistently:
001-about-page-heading-test
Keep variant keys short and predictable:
control
test_group_1
test_group_2The feature flag key and variant keys must match exactly in PostHog and your WordPress script, including capitalization. A mistyped flag key can leave visitors on control instead of entering the intended experiment.
If visitors see the test before login, select Device under the flag’s Release conditions to keep the variant stable through identification on the same browser. (+) Keep user bucketing when an identified person needs the same assignment across devices.
Step 3: Create the Variant Modules in WordPress
Now build the content variants on the duplicated WordPress page.
In Divi Builder, open the duplicated page and find the module or section you want to test. Duplicate that module once for each test variant. The worked example uses Divi 4; the next step includes Divi 5, Gutenberg, and Elementor equivalents.
For a three-way test, you might have:
Control: the original module.TG1: the first test version.TG2: the second test version.

The only difference between these three versions is the heading. Keep the surrounding layout, spacing, buttons, and form behavior the same.
Step 4: Give Each Variant a Unique CSS ID
Your script needs stable selectors so it can hide and show the correct module.
In Divi 4:
- Open the module settings.
- Go to Advanced → CSS ID & Classes.
- Add a unique CSS ID, without the leading
#.
Example IDs:
experiment-control
experiment-tg1
experiment-tg2Use IDs that describe the experiment, not the design. Avoid IDs like blue-heading or new-copy, because they become confusing once the test changes or gets reused.
In Divi 5, use Advanced → Attributes for the ID. (+) For Gutenberg and Elementor, use these equivalents:
| Builder | Set each variant’s ID | Add the runner |
|---|---|---|
| Gutenberg | Select the heading or a supporting Group block, then Advanced → HTML anchor. (+ / +) Enter the ID without #. | Insert a Custom HTML block for the code. (+) |
| Elementor | Select the widget or container, then Advanced → CSS ID. (+) Enter the ID without #. | Add an HTML widget and paste the code. (+) |
Confirm the ID on the published preview: right-click the heading, choose Inspect, and find its module or block wrapper in the browser’s Elements panel. It should have an attribute such as id="experiment-control". Check that each ID appears exactly once; copied desktop and mobile modules must not share it.
Step 5: Hide Test Variants by Default
Hide the treatment modules before the scripts begin loading and executing, so visitors do not see all versions together while the page loads.
In Divi 4:
- Open the settings for each test-group module.
- Go to Advanced → Custom CSS → Module Elements.
- In Main Element, add:
display: none !important;Apply this to the test variants, such as TG1 and TG2.
Leave the control visible by default and hide only the treatment modules. That keeps the original page usable when JavaScript or PostHog does not load. The runner applies its display styles with !important, so it can reveal the assigned module after the flag resolves.

For another builder, put the same hiding rule in the page’s initial stylesheet, before the runner executes:
#experiment-tg1,
#experiment-tg2 {
display: none !important;
}Step 6: Add the Experiment Runner
Add the bundle and experiment configuration near the bottom of the duplicated page. In Divi, use a Code module, under Content → Text. (+) Save and exit the Visual Builder before testing the page.
The code uses PostHog Page Experiments 0.2.0, our free, MIT-licensed JavaScript package, independent of PostHog. It waits for the page, checks eligibility, handles fallbacks and supports URL previews. Keep that version pinned so an update cannot change the code during your test.
<script src="https://cdn.jsdelivr.net/npm/@99ways/[email protected]/dist/posthog-page-experiments.min.js"></script>
<script>
window.runExperiment('001-about-page-heading-test', {
isEligible: async () => {
// A URL preview of a variant reads no flag; every other visit waits for consent.
if (!new URLSearchParams(location.search).get('001-about-page-heading-test')) {
await window.analyticsConsent
if (window.posthog?.has_opted_out_capturing?.()) return false
}
return ['#experiment-control', '#experiment-tg1', '#experiment-tg2'].every(selector =>
document.querySelectorAll(selector).length === 1
)
},
variants: {
control: [],
test_group_1: [
{
selector: '#experiment-control',
updates: { style: { display: 'none' } },
},
{
selector: '#experiment-tg1',
updates: { style: { display: 'block' } },
},
],
test_group_2: [
{
selector: '#experiment-control',
updates: { style: { display: 'none' } },
},
{
selector: '#experiment-tg2',
updates: { style: { display: 'block' } },
},
],
},
}).catch(error => console.error('Experiment error:', error))
</script>Replace the feature flag key, in both places, and selectors with the exact values from your PostHog experiment and WordPress modules. Keep the same variant keys in both places. For a two-arm test, remove test_group_2 from PostHog, its module and hiding CSS, its selector in isEligible, and its handler in variants.
The package defaults to accepting visitors and skips selectors that match nothing. This isEligible callback requires exactly one match for every control and treatment target, after DOM readiness and before the URL override or flag is read. If any target is missing or duplicated, this call returns null without evaluating the flag. Keep the code scoped to the intended page and add any page-state checks your hypothesis requires.
isEligible first waits for window.analyticsConsent, which the consent function in our install guide resolves when the visitor accepts; URL previews skip the wait because they read no flag. Until acceptance, control shows and the flag is not read. A flag read before acceptance would drop its exposure: opting in does not resend it, and with the SDK’s default deduplication, later reads of the same value can stay suppressed across page loads. (+) The test therefore measures visitors who allow analytics, from the moment they accept: visitors who never accept see control and stay outside the analysis, and a late acceptor may already have read the original heading. With other consent code, create the promise in a head script that your consent manager does not block, and resolve it right after posthog.opt_in_capturing(). On a site without consent gating the value is undefined, and the check continues at once.
The empty control: [] handler deliberately leaves the original page unchanged. An unavailable, disabled, empty, timed-out, or unknown flag value falls back to control.
The example reveals a heading module with display: block. If your target uses a flex or grid layout, use its intended display value instead.
Gutenberg removes script tags when the editor lacks unfiltered_html permission, and Elementor restricts the HTML widget to administrators by default. (+ / +) If the saved page has lost the script, ask the site’s developer to load it through the page template instead.
The runner applies the variant once; it does not watch for replacements or undo earlier changes. (+) If AJAX or the builder replaces the content, make sure the new markup starts with control visible and treatments hidden, then run the experiment once for that content. If the page updates repeatedly, apply the variant in the code that renders it.
Step 7: QA Every Variant Before Launch
Use the runner’s URL preview to check each variant before launch. The query parameter’s name is the feature flag key and its value is a configured variant:
https://example.com/about/?001-about-page-heading-test=control
https://example.com/about/?001-about-page-heading-test=test_group_1
https://example.com/about/?001-about-page-heading-test=test_group_2A configured URL variant takes precedence over PostHog, so reviewers can inspect it before the experiment is launched. Eligibility still runs first. An unknown variant falls back to control. A forced preview bypasses the runner’s flag evaluation; pageview and conversion tracking elsewhere on the page can still fire.
At minimum, test:
- Control renders correctly and remains visible with JavaScript disabled.
- Each treatment renders correctly, and only one version is visible.
- The exact feature flag key and variant keys match PostHog.
- Mobile, tablet, and desktop layouts work without an unacceptable control-to-treatment flash.
- The form accepts a valid submission and sends the expected conversion event.
- In a safe preview, removing a required treatment target or duplicating its ID prevents this runner call from evaluating the flag.
- No relevant browser-console errors appear.
Then remove the variant parameter. While the experiment is a draft with its flag disabled, the page should show control. The console summary can show source: posthog when the SDK answers without a configured variant, or source: fallback when the client is unavailable or times out. Read flagValue, reason, and note together; showing control alone does not prove a live assignment. Real assignment is the required check on the live URL in Step 9.
Open the browser console to see the 0.2.0 assignment summary: a forced preview reports source: url, and a failed eligibility check reports source: ineligible. Add ?experiment-debug for step-by-step logs, or ?experiment-debug=001-about-page-heading-test to limit them to this flag. Set log: 'silent' in the options to turn logging off.
If your site calls posthog.getFeatureFlag() directly instead, wait for posthog.onFeatureFlags() before reading it and use PostHog’s flag overrides for QA; the preview URLs above are the runner’s own feature. (+)
Step 8: Set the Primary Metric in PostHog
Before launching, choose the metric you will use to judge the test. It should measure the outcome your hypothesis is trying to improve.
For the headline hypothesis above, measure successful lead-form submissions: use your form’s confirmed-success event, not a click on the submit button. This example calls it lead_form_submitted.
In PostHog:
- Open the draft experiment.
- Under Primary metrics, add a Funnel metric.
- Add
lead_form_submitted, or your actual successful-submit event, as the step after exposure. - Set a conversion window that matches how long visitors reasonably need to complete the form.
- Save the metric.
The journey being measured is:
Exposure to 001-about-page-heading-test
-> lead_form_submitted
Configured flag exposure: $feature_flag_called or $experiment_exposure
$feature_flag = 001-about-page-heading-test
$feature_flag_response = control | test_group_1 | test_group_2An experiment funnel already includes its configured exposure as the first step. (+) Add only the events that happen afterward; do not add a generic step that counts calls to unrelated flags.
Do not automatically treat $feature_flag_called as valid exposure. Read the flag when the visitor can encounter the tested heading. (+) The eligibility check proves the targets exist; it does not prove that a hidden tab opened or a visitor scrolled to a section.
For a delayed or hidden section, choose Edit exposure criteria → Custom event and capture exposure when the tested experience becomes available, including for control. (+) That event must carry $feature/001-about-page-heading-test with the resolved variant value. Flag-based exposure instead uses $feature_flag_called or $experiment_exposure; both carry $feature_flag and $feature_flag_response, as shown above. Check which event your experiment’s exposure criteria name. (+)
If leads are too sparse to measure in an early-funnel test, you can use a CTA click or form visit. Be clear about what that tells you: more people reached the next step, but you have not established an increase in leads. For pricing or offer changes, a form submission, purchase or qualified lead is usually more useful. Our A/B testing metrics guide explains how to choose.
Step 9: Move the Validated Implementation Into Production
Keep the existing live URL where possible. After the copy passes QA, move the tested modules, treatment-hiding CSS and runner to that live page, or use your normal staging-to-production process.
Do not make swapping the original and duplicated page slugs the default deployment method. A URL switch can affect internal links, canonicals, redirects, caches, forms, and search visibility. Use it only as a planned migration with its own checks.
Before updating the live page, record its URL, the preview URL, experiment flag, variant keys, selectors, primary metric, implementation version, and rollback steps. Then verify the live page while the PostHog experiment is still a draft.
- Control is still the default when the runner or PostHog is unavailable.
- Every forced variant works on the live URL.
- Removing the preview parameter shows control while the flag is disabled.
- The page action and conversion event still work.
- Caches and optimization plugins are not serving the pre-experiment markup or script.
Verify real assignment before launch
Before launch, test real SDK assignment on the live page with the flag restricted to QA traffic. Settle device or user bucketing and the exposure criteria before this check.
- Open the experiment’s feature flag and record the intended production release conditions. Set every existing public condition’s rollout to 0%. A pass on any condition set can admit a visitor, so adding a QA condition alone does not restrict an enabled flag. (+)
- Add a condition set first in the list:
$initial_utm_sourceequalsposthog-qa, rollout 100%, with its Optional override set totest_group_1. Enable the flag and save; keep the experiment as a draft. - Open a fresh browser profile at the live URL with
?utm_source=posthog-qa&experiment-debug. Use no runner variant parameter or SDK override. Confirmsource: posthog,test_group_1as the variant, and the matching visible heading. - In Activity, confirm the configured exposure has this flag’s key and the assigned variant. Complete the form and find the successful-submit event afterward for the same participant. Use the exposure properties from Step 8.
- Repeat with the Optional override set to each remaining variant, using a fresh browser profile each time. Also check that another fresh profile without the QA parameter stays on control while public rollout is 0%.
The web SDK sends $initial_utm_source with flag requests by default. (+) Starting in a fresh profile makes the QA marker the initial source; use that exact property in the condition. If you use Chrome Incognito instead, close every Incognito window before each new check so the previous session’s source and identity are cleared. (+)
If capture waits for consent, accept the banner on the live page during these checks. Control should show until you accept; then the assigned variant and its exposure should appear. On a clean, unforced visit, a treatment that appears before acceptance points to an early flag read: check the assignment summary and the exposure event.
Once every variant passes, disable the flag while you remove the QA condition and restore the production release conditions. Then launch the experiment in Step 10. Keep QA visits out of the production analysis.
Add the launch date and any page or template changes made during the run to the experiment record. This makes unexpected data changes easier to trace.
Step 10: Launch the Experiment
Launch only after the implementation is live and the restricted assignment, exposure, and conversion checks pass for every variant. In PostHog, reconfirm the traffic split, variants, release conditions, primary metric, and conversion window, then click Launch. Launching enables the flag and starts the experiment.
Keep the targeting, variant content, and split stable while collecting data. Do not change the variants mid-experiment unless you are correcting a documented implementation defect.
How to Verify the Setup in PostHog
After launch, check a visit that receives a real assignment:
Follow it from the displayed variant through to the completed form submission:
- Open the live page in a fresh browser profile with
?experiment-debugand no QA parameter, forced variant or SDK override, and check the summary and the visible heading as in Step 9. Reload, and log in if the journey includes it, to check that the assignment holds. - In PostHog Activity, find this visit’s exposure, with the properties from Step 8.
- Complete a successful form submission and find
lead_form_submittedafter the exposure, for the same participant and within the conversion window. A form on another domain or a server-side lead event must preserve that participant’s identity.
PostHog attributes later metric events to the variant through the participant’s exposure. (+) You do not need to copy the flag property onto every conversion event. For a broader check of missing, duplicate, or disconnected conversions, use the conversion tracking audit.
- Enable test-account filtering in the experiment’s exposure criteria. (+) It applies your project’s internal-user filters, so define them first with our internal-user filtering guide and check that your QA browser matches them. Keep staff, forced previews, unpublished pages and staging traffic out of the production analysis.
- Check that exposures reach each variant and investigate any sample ratio mismatch warning before trusting the result. (+)
- Review session recordings, if enabled, for unexpected visual behavior or broken modules.
If you tested the flag before launch, returning browsers can have their exposure event cached across sessions. (+) Set advanced_feature_flags_dedup_per_session: true in the PostHog initialization when you need the web SDK to emit the exposure again in a new session.
Once those checks pass, use metric breakdowns to see whether the result differs by device or traffic source.
Common Mistakes to Avoid
Using the wrong feature flag key
If every ordinary visit stays on control, compare the flag key in the console summary with PostHog. Inspect the raw flag value and note: a disabled flag, an unknown value, and a failed load can all leave control visible for different reasons.
Showing variants before flags load
Block the runner in a safe preview and reload. If the treatments are visible, fix the hiding CSS in the delivered page before investigating assignment.
Testing too many changes at once
Compare each variant with the control before launch. An unrelated difference makes it harder to attribute the result to the heading.
Forgetting mobile QA
Check the published page at each breakpoint. Builder visibility settings can hide a parent of the target even when its ID still exists, so a passing selector check alone is insufficient.
Measuring the wrong success event
Submit the form, then compare the event’s name and properties with your saved metric. Check that the successful submission is counted, not just the button click.
Interpreting a result before checking the implementation
Before concluding that a variant lost, check the selector, the loaded script, the metric definition, and event tracking. If those disagree, fix the implementation before interpreting the result.
Changing variants during the experiment
Compare the timing of an unexpected shift with the page and template change log. Record any defect and its fix, then decide whether the affected data must be excluded or the experiment restarted.
Before you interpret the result
If visitors see the wrong module, their conversions are missing, or the metric measures the wrong action, the result may reflect those problems rather than the change you meant to test. Check what visitors saw and what PostHog recorded before deciding which version to keep.
Frequently Asked Questions
Do I need a WordPress A/B testing plugin?
No. The runner can be loaded through a code module or HTML widget; it is a JavaScript package rather than a WordPress plugin. A plugin used to duplicate a draft page is separate from experiment assignment.
What if visitors briefly see the control before the treatment?
That is client-side flicker. Load PostHog and the runner as early as your performance budget allows, keep treatments hidden by default, and consider a short, fail-safe concealment of only the affected heading when a flash is unacceptable. Do not hide the entire page indefinitely.
What happens when PostHog does not load?
Control stays visible. The runner waits up to 4,000 milliseconds by default for PostHog and its first flag notification, then applies the default variant. (+) You can change the limit with featureFlagTimeoutMs; increasing it allows a longer wait before falling back. The wait starts after isEligible finishes, so it does not run while consent is pending.
Should I keep the experiment code after the test ends?
No. Build the chosen version into the page, then remove the experiment’s modules, hiding CSS and runner configuration. Disable or delete the flag after its checks have been removed. (+) Otherwise, the old experiment code can get in the way when you next edit the page.
Why do I see zero conversions even though the form gets submissions?
Submissions can arrive without a matching experiment exposure. Only conversions after a valid exposure, for the same participant and within the conversion window, count. (+) A forced runner preview skips normal flag evaluation. On a consent-gated site, a flag read before acceptance loses that visitor’s exposure; see Step 6. An external form or server event using an unlinked identity can also leave the experiment empty even while the form’s own submission total rises.
Will caching or script optimization affect the experiment?
Yes. A cache can serve an older copy of the modules or runner code, and script delay settings can postpone assignment. (+ / +) Purge the relevant caches after deployment, open the page as a logged-out visitor, and inspect the delivered HTML and scripts. Repeat the preview and ordinary-assignment checks with the optimization settings visitors actually receive.

