Run A/B Tests on Webflow with Optimizely Web Experimentation
TL;DR
Webflow sites are unusually good candidates for client-side A/B testing and unusually easy to break with it. Good, because the markup is clean, class names are stable, and a designer can build the variation's target state visually before anyone writes a selector. Easy to break, because the site is republished from a visual editor by people who are not thinking about your experiment, and a republish can rename the class your variation depends on without anyone noticing until the results go flat.
This guide covers installing Optimizely Web Experimentation on Webflow, forwarding Webflow's native form submissions into Optimizely as custom events, and building variations that survive the next publish. If your conversion is a form fill — which on Webflow it almost always is — size the test on the sample size calculator before you build anything, and read how long to run an A/B test if the answer comes back longer than you expected.
How Optimizely Web Experimentation Runs on Webflow
Webflow serves static HTML from its own CDN with a small runtime for interactions and forms. The Optimizely snippet goes into the site-wide head, decides the visitor's variation before the page paints, and applies DOM changes. Webflow's form handler posts submissions to Webflow's servers and then swaps the form for a success state in the page — which is the moment a conversion becomes observable.
flowchart TD
A[Visitor loads a Webflow page] --> B[Site-wide head: Optimizely snippet]
B --> C[Variation applied before paint]
C --> D[Visitor submits a Webflow form]
D --> E[Webflow posts the submission and shows the success state]
E --> F{Form name in the allowlist?}
F -->|yes| G[Push onto the Optimizely queue]
F -->|no| H[Dropped, silently]
G --> I[Custom event recorded against the visitor's variation]Where Custom Code Lives in Webflow
There are two places to put code and they behave differently. Site settings → Custom code → Head code applies to every page and is where the snippet belongs. Page settings → Custom code applies to one page and loads after the site-wide head. A snippet placed at page level runs on that page only, which produces the confusing situation where the experiment works on the page you tested and nowhere else.
Site-wide custom code requires a paid site plan. On a free Webflow site there is no supported way to install the snippet, and no amount of embed-block improvisation changes that — an embed block renders in <body>, far too late to prevent flicker.
The Publish Gap
Custom code in site settings only takes effect when the site is published. This catches almost everyone once: the snippet is pasted, the staging domain is checked, nothing happens, and twenty minutes go into debugging a script that was never deployed. Publish first, then debug.
Prerequisites
A paid Webflow site plan, which is what unlocks site-wide custom code.
The Optimizely Web Experimentation snippet URL for your project.
Named forms. Every Webflow form has a
Namein the settings panel, which becomes thedata-nameattribute. The names are your allowlist keys, so agree on them before you write any code.Custom events created in Optimizely for each form you intend to count, with API names noted. An event pushed under a name that does not exist in Optimizely is discarded without an error.
Event properties created in Optimizely for any field you want to segment by — fifteen per event, five predefined and ten custom.
Step 1: Add the Snippet to the Site-Wide Head
Paste the snippet into Site settings → Custom code → Head code, as the very first thing in that field, then publish.
<script src="https://cdn.optimizely.com/js/YOUR_PROJECT_ID.js"></script>
Do not add async or defer. Webflow does not reorder head code, so whatever is first in that field is first on the page — which makes this one of the few platforms where getting the snippet ahead of everything else is genuinely easy. If you also use a consent manager, put the snippet above it and configure the consent tool to allow it, rather than moving the snippet down.
Step 2: Forward Webflow Form Submissions as Custom Events
Webflow's runtime does not emit a JavaScript event on successful submission. What it does reliably is add a success state to the DOM, and it fires a jQuery submit on the form itself. Listening to the submit and reading the form's data-name is the approach that keeps working across Webflow runtime updates, because data-name is part of the published markup rather than an internal API.
The Event Map
The allowlist is keyed by form name, which is what makes this integration different from an analytics one: the interesting distinction is not what kind of event happened but which form it was.
{
"events": {
"Request a demo": "demo_requested",
"Newsletter": "newsletter_signup",
"Contact": "contact_submitted"
},
"numericProperty": "seats",
"properties": [
{ "from": "plan", "to": "Plan" },
{ "from": "companySize", "to": "Company Size" },
{ "from": "source", "to": "Source" }
]
}
value is a reserved Optimizely tag holding a plain number, and a seat count on a demo request is a reasonable thing to put in it. There is no revenue on a Webflow form, so no revenue tag is emitted at all — an absent tag is correct here, and better than a zero that a revenue metric would happily average.
The Form Listener
Add this to the site-wide footer code, not the head: it needs the Webflow runtime and jQuery to exist.
const FORWARDED_FORMS = {
'Request a demo': 'demo_requested',
Newsletter: 'newsletter_signup',
Contact: 'contact_submitted',
};
function fieldsOf(form) {
const fields = {};
new FormData(form).forEach(function (value, key) {
fields[key] = value;
});
if (typeof fields.seats === 'string') {
fields.seats = Number(fields.seats);
}
return fields;
}
document.addEventListener('submit', function (event) {
const form = event.target;
const formName = form.getAttribute('data-name');
const eventName = FORWARDED_FORMS[formName];
if (!eventName) {
return;
}
const fields = fieldsOf(form);
const tags = {};
if (typeof fields.seats === 'number' && Number.isFinite(fields.seats)) {
tags.value = fields.seats;
}
window['optimizely'] = window['optimizely'] || [];
window['optimizely'].push({
type: 'event',
eventName: eventName,
tags: tags,
properties: {
Plan: fields.plan,
'Company Size': fields.companySize,
Source: fields.source,
},
});
}, true);
The listener is attached in the capture phase on document, which matters because Webflow's own handler calls preventDefault and stops propagation on some form configurations. Capturing at the document level runs before that happens.
What the Payload Looks Like
A demo request from a five-seat prospect produces this:
{
"type": "event",
"eventName": "demo_requested",
"tags": { "value": 5 },
"properties": { "Plan": "Pro", "Company Size": "50-200", "Source": "pricing-page" }
}
The Webflow integration demo runs the same mapping, including a form a designer added later that nobody put in the allowlist — which is the failure mode this integration actually has.
Step 3: Design Variations That Survive a Republish
Webflow class names are editable in the Designer, and renaming a class is a normal design action. A variation whose selector is .hero-heading-2 breaks the moment someone renames that class, and the break is silent: the variation simply applies nothing and the visitor sees the original.
Two habits prevent most of it. Target a stable attribute rather than a styling class — an id set in element settings, or a custom attribute like data-test-target="hero-heading" — and agree that attributes with a data-test- prefix are not to be edited without checking. And where a variation must change copy, prefer changing the text of one element over restructuring a section, because restructuring is what designers do most often.
Gotchas
Symbols and components. A change applied to an element inside a Webflow component appears on every instance of it. If the variation should only apply on one page, scope the experiment's URL targeting rather than relying on the selector.
The success state is not a page load. Webflow forms submit over AJAX and stay on the same URL. A conversion metric configured as a pageview of a thank-you page will never fire.
Duplicate form names. Webflow does not enforce unique form names across pages, and two forms called Contact will both map to contact_submitted. That is usually what you want, but it is worth being deliberate about rather than surprised by.
Localised sites. Webflow Localization serves translated markup at different URLs; class names carry over but text selectors do not. Target attributes, not text.
Verifying the Integration
Publish the site, load a page, and confirm in the console that window.optimizely is defined before the Webflow runtime script has finished loading. If it is not, the snippet is not first in the head code field.
Then submit each allowlisted form once and confirm the matching push appears on the queue with the properties you expect. Finally, submit a form that is not in the allowlist and confirm nothing is pushed — the drop is the behaviour you are relying on, and it is worth seeing once so that a later flat metric prompts you to check the map rather than the code.