<?xml version="1.0" encoding="utf-8"?><?xml-stylesheet type="text/xsl" href="rss.xsl"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>BFFless Blog</title>
        <link>https://docs.bffless.com/blog/</link>
        <description>BFFless Blog</description>
        <lastBuildDate>Mon, 13 Apr 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <item>
            <title><![CDATA[Your Git Branches Are Already A/B Test Variants]]></title>
            <link>https://docs.bffless.com/blog/ab-testing-landing-pages/</link>
            <guid>https://docs.bffless.com/blog/ab-testing-landing-pages/</guid>
            <pubDate>Mon, 13 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Skip Unbounce. Make every git branch a deployable landing-page variant, split traffic at the edge, and read a cookie to attribute conversions.]]></description>
            <content:encoded><![CDATA[<img src="https://docs.bffless.com/img/ab-test-variant-hero.png" alt="A horizontal git commit graph on a dark navy grid: a single trunk with three branches diverging upward in purple, teal, and coral, each ending in a small wireframe landing-page mockup, with a glowing 'edge' box on the right connecting thin lines to all three branch endpoints">
<p>If you've ever shipped a landing page from a <code>dist/</code> folder, you already have the hard parts of an A/B testing platform. You have build artifacts. You have branches. You have a CI pipeline that knows how to push them somewhere. The thing you're missing — the thing Unbounce and Instapage charge $200–$600/month for — is a router that picks one of those builds per visitor and remembers the choice. That's it. That's the whole feature.</p>
<p>This post is about wiring that router in front of your existing build pipeline. Each variant stays a normal git branch with a normal build artifact. BFFless splits traffic across them at the edge and sets a cookie so visitors stay sticky. No SDK, no template lock-in, no per-page pricing.</p>
<div class="theme-admonition theme-admonition-info admonition_QdyP alert alert--info"><div class="admonitionHeading_yjbo"><span class="admonitionIcon_SgN0"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>This post is itself an A/B test</div><div class="admonitionContent_cglA"><p>Meta moment: the page you're reading right now is being served by the exact mechanism it describes. We deployed two versions of this article as separate aliases (<code>main</code> and <code>blog-git-native</code>) and BFFless is splitting traffic 50/50 between them. You're currently reading the <strong><code>git-native</code></strong> variant — same takeaway as the other one, just framed for devs first instead of PPC operators.</p><p>Want to see the original? <a href="https://docs.bffless.com/blog/ab-testing-landing-pages/?variant=default">Switch to <code>?variant=default</code></a>. To pin yourself to this version, use <a href="https://docs.bffless.com/blog/ab-testing-landing-pages/?variant=git-native"><code>?variant=git-native</code></a>. The <code>__bffless_variant</code> cookie will keep you on whichever you land on so the rest of the post stays consistent. Open in an incognito window to get re-rolled.</p></div></div>
<img src="https://docs.bffless.com/img/ab-test-split-v2.png" alt="Two variants of this blog post shown side-by-side in the BFFless blog index, each with its own hero image — left titled 'A/B Testing Landing Pages Without the Enterprise Price Tag' with a 50/50 traffic-split illustration, right titled 'Your Git Branches Are Already A/B Test Variants' with a git-branches-to-edge illustration">
<p>Two things this screenshot makes obvious that a client-side A/B testing tool can't:</p>
<ul>
<li class=""><strong>It's the whole site, not a page.</strong> Because each variant is a complete build artifact from its own git branch, the title, lede, section headings, meta description, OG card, and even the blog-index preview you see above all change together. You're not testing a button color — you're testing a whole positioning, end to end, across every URL the site serves.</li>
<li class=""><strong>It's decided at build time and at the edge.</strong> There's no flash of the wrong content, no client-side flicker, no JavaScript swap-in mid-paint. By the time the first byte of HTML reaches the browser, the variant has already been chosen — what loads is what was in <code>dist/</code>, fully cached, fully fast, indistinguishable from a regular static page.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_wpU8" id="the-just-give-me-a-router-problem">The "just give me a router" problem<a href="https://docs.bffless.com/blog/ab-testing-landing-pages/#the-just-give-me-a-router-problem" class="hash-link" aria-label="Direct link to The &quot;just give me a router&quot; problem" title="Direct link to The &quot;just give me a router&quot; problem" translate="no">​</a></h2>
<p>Drag-and-drop builders are great at what they do. But the moment your team has a real frontend stack — a design system, a component library, a React or Astro setup that already builds into <code>dist/</code> — the same friction points show up every time you try to plug a builder into your workflow:</p>
<ul>
<li class=""><strong>A/B testing is a premium feature.</strong> Unbounce's Smart Traffic, Instapage's experimentation suite, and Leadpages' split testing all sit on plans that run $200–$600/month per seat before you're testing at any real volume.</li>
<li class=""><strong>Templates cap what you can ship.</strong> Custom components, design systems, and anything your frontend team has already built in React or Astro tend not to survive the round-trip through a WYSIWYG editor.</li>
<li class=""><strong>Client approval is its own product.</strong> Sending a stakeholder a preview of variant B without showing them the live split usually means upgrading <em>again</em> to the tier with share links.</li>
<li class=""><strong>Page speed is the hidden tax.</strong> Google Ads Quality Score punishes slow LCPs. Every builder layers its own runtime on top of your page; static HTML doesn't.</li>
</ul>
<p>None of these are fatal on their own. Stack them together and you end up paying a premium tool to do a job that mostly consists of serving HTML.</p>
<h2 class="anchor anchorTargetStickyNavbar_wpU8" id="why-a-build-artifact-is-the-right-primitive">Why a build artifact is the right primitive<a href="https://docs.bffless.com/blog/ab-testing-landing-pages/#why-a-build-artifact-is-the-right-primitive" class="hash-link" aria-label="Direct link to Why a build artifact is the right primitive" title="Direct link to Why a build artifact is the right primitive" translate="no">​</a></h2>
<p>Landing pages are the ideal case for "just ship the bundle". They're small, they're read-heavy, and the two metrics that matter most — Quality Score for paid traffic and conversion rate for everything else — both reward raw page speed. A pre-built bundle gives you the fastest TTFB and LCP you can physically achieve, and it scales to any traffic spike without an autoscaler.</p>
<p>The other reason it's the right primitive is that you already have one. Whatever your team builds in — React, Astro, Eleventy, Hugo, a folder full of HTML — produces a directory that can be served as-is. Each variant is just another version of that directory.</p>
<p>The missing piece is usually traffic splitting. That's what BFFless adds on top.</p>
<h2 class="anchor anchorTargetStickyNavbar_wpU8" id="how-ab-testing-works-on-bffless">How A/B testing works on BFFless<a href="https://docs.bffless.com/blog/ab-testing-landing-pages/#how-ab-testing-works-on-bffless" class="hash-link" aria-label="Direct link to How A/B testing works on BFFless" title="Direct link to How A/B testing works on BFFless" translate="no">​</a></h2>
<p>The primitive is simple: every variant is a <strong>deployment alias</strong>, and traffic splitting routes visitors across aliases by weight.</p>
<img src="https://docs.bffless.com/img/traffic-splitting-settings.png" alt="BFFless traffic splitting configuration showing two aliases at 50% each">
<p>When a visitor lands on your domain, BFFless does roughly this:</p>
<ol>
<li class="">Check for an existing <code>__bffless_variant</code> cookie. If it's set, route to that alias.</li>
<li class="">Otherwise, evaluate traffic rules (query parameters, cookies). If one matches, force that alias.</li>
<li class="">Otherwise, pick an alias using weighted random selection — for weights <code>[50, 30, 20]</code>, random 0–50 routes to the first alias, 51–80 to the second, 81–100 to the third.</li>
<li class="">Resolve the alias to its current deployment SHA (and optional path override), fetch the file from storage, and serve it back.</li>
<li class="">Set the <code>__bffless_variant</code> cookie so the visitor sticks to that variant on return visits.</li>
</ol>
<p>The variant cookie is intentionally readable from JavaScript (not <code>HttpOnly</code>), so your analytics layer can pick it up and attribute conversions without any server-side glue. The selected alias is also returned in the <code>X-Variant</code> response header if you prefer reading it from the network layer.</p>
<p>Visually, a single request from a first-time visitor looks like this:</p>
<!-- -->
<p>Step by step:</p>
<ol>
<li class=""><strong>Request hits BFFless.</strong> The visitor's browser sends a normal <code>GET /</code> to your landing-page domain. If they've been here before, the request also carries a <code>__bffless_variant</code> cookie naming the alias they were assigned last time.</li>
<li class=""><strong>Domain config lookup.</strong> BFFless resolves the <code>Host</code> header to a domain mapping and pulls the traffic config for it: the list of aliases, their weights, sticky-session settings, and any traffic rules. If the domain has only one alias, this whole flow short-circuits and serves it directly.</li>
<li class=""><strong>Cookie branch — sticky session.</strong> If sticky sessions are on and the cookie's alias is still valid (still in the weights list, or still referenced by an active rule), BFFless reuses that alias and skips selection entirely. This is why a returning visitor never flips between variants mid-test.</li>
<li class=""><strong>Rule branch — forced routing.</strong> If there's no usable cookie, BFFless evaluates traffic rules in order: query parameters first, then cookies, then headers. The first match wins and forces that alias. This is the path that powers <code>?version=headline-urgency</code> preview links and segment overrides like <code>Cookie: plan=enterprise</code>.</li>
<li class=""><strong>Random branch — weighted pick.</strong> With no cookie and no matching rule, BFFless rolls a weighted random number across the configured splits. For weights <code>[34, 33, 33]</code> the math is exactly what you'd expect: 34% of new visitors land on the first alias, 33% on each of the other two.</li>
<li class=""><strong>Alias resolution.</strong> Aliases are mutable pointers — <code>landing-headline-urgency</code> resolves to whichever immutable deployment SHA is currently promoted to it, plus an optional <strong>path override</strong> if the variant lives in a subfolder of a single shared deployment instead of its own branch build.</li>
<li class=""><strong>Config returns SHA + path.</strong> The domain config hands back the resolved deployment coordinates: the commit SHA (e.g. <code>abc123</code>) and the path prefix (e.g. <code>/dist</code>). Together these form the storage key prefix for every file in that variant.</li>
<li class=""><strong>Storage fetch.</strong> BFFless requests the file from whichever storage backend you've configured — S3, GCS, Azure Blob, MinIO, or the local filesystem — using the key <code>owner/repo/abc123/dist/index.html</code>. The storage adapter is pluggable; the routing logic above doesn't care which one is wired up.</li>
<li class=""><strong>Bytes back.</strong> The storage backend returns the raw file contents. BFFless streams them through without rewriting HTML or injecting any runtime — what was in your <code>dist/</code> folder is what the visitor gets.</li>
<li class=""><strong>Response with cookie + header.</strong> BFFless writes the body back to the visitor along with <code>Set-Cookie: __bffless_variant=headline-urgency</code> (so the next request is sticky) and <code>X-Variant: headline-urgency</code> (so your edge logs, analytics, and curl debugging can see which variant was served without parsing cookies).</li>
</ol>
<p>That's the whole mechanism. Nothing you put in front of it — routing, caching, rules, previews — requires a new SDK or a new plan tier.</p>
<h2 class="anchor anchorTargetStickyNavbar_wpU8" id="shipping-a-headline-test-end-to-end">Shipping a headline test, end to end<a href="https://docs.bffless.com/blog/ab-testing-landing-pages/#shipping-a-headline-test-end-to-end" class="hash-link" aria-label="Direct link to Shipping a headline test, end to end" title="Direct link to Shipping a headline test, end to end" translate="no">​</a></h2>
<p>Here's a concrete walkthrough. Say you're testing three headline variants on <code>landing.clientname.com</code>.</p>
<h3 class="anchor anchorTargetStickyNavbar_wpU8" id="1-deploy-each-variant-as-its-own-alias">1. Deploy each variant as its own alias<a href="https://docs.bffless.com/blog/ab-testing-landing-pages/#1-deploy-each-variant-as-its-own-alias" class="hash-link" aria-label="Direct link to 1. Deploy each variant as its own alias" title="Direct link to 1. Deploy each variant as its own alias" translate="no">​</a></h3>
<p>Branch per variant, one GitHub Actions workflow, one alias per push. The action uploads whatever's in <code>dist/</code> to BFFless under an alias named after the branch:</p>
<div class="language-yaml codeBlockContainer_TblA theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockTitle_gEf4">.github/workflows/deploy.yml</div><div class="codeBlockContent_etv3"><pre tabindex="0" class="prism-code language-yaml codeBlock_hwC8 thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_tzmA"><span class="token-line" style="color:#F8F8F2"><span class="token key atrule">on</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">push</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">branches</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token plain">main</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> headline</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">outcome</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> headline</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">urgency</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">jobs</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">deploy</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">runs-on</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> ubuntu</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">latest</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">steps</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">uses</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> actions/checkout@v4</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">run</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> pnpm install </span><span class="token important">&amp;&amp;</span><span class="token plain"> pnpm build</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">uses</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> bffless/upload</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">artifact@v1</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">        </span><span class="token key atrule">with</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">          </span><span class="token key atrule">path</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> dist</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">          </span><span class="token key atrule">api-url</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> $</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> vars.BFFLESS_URL </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">          </span><span class="token key atrule">api-key</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> $</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> secrets.BFFLESS_API_KEY </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">          </span><span class="token key atrule">alias</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> landing</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">$</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> github.ref_name </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></span></code></pre></div></div>
<p>After three pushes you have <code>landing-main</code>, <code>landing-headline-outcome</code>, and <code>landing-headline-urgency</code> — three live static sites, each with its own preview URL. Git is your variant manager.</p>
<h3 class="anchor anchorTargetStickyNavbar_wpU8" id="2-configure-the-split">2. Configure the split<a href="https://docs.bffless.com/blog/ab-testing-landing-pages/#2-configure-the-split" class="hash-link" aria-label="Direct link to 2. Configure the split" title="Direct link to 2. Configure the split" translate="no">​</a></h3>
<p>In <strong>Admin → Settings → Domain Mapping → Traffic</strong>, attach those three aliases to <code>landing.clientname.com</code>:</p>
<div class="language-text codeBlockContainer_TblA theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_etv3"><pre tabindex="0" class="prism-code language-text codeBlock_hwC8 thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_tzmA"><span class="token-line" style="color:#F8F8F2"><span class="token plain">landing-main:             34%</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">landing-headline-outcome: 33%</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">landing-headline-urgency: 33%</span><br></span></code></pre></div></div>
<p>Turn on <strong>sticky sessions</strong> so return visitors stay on the same variant, and add a <code>?version=</code> query-param rule for each alias:</p>
<img src="https://docs.bffless.com/img/recipe-ab-setup.png" alt="Traffic splitting configuration with three aliases and query parameter rules">
<p>Now anyone can preview a specific variant by appending <code>?version=headline-outcome</code> to the URL — which is exactly the "share a preview with the client" workflow that enterprise builders charge extra for. Send the three links to your PM, let them pick, done.</p>
<div class="theme-admonition theme-admonition-tip admonition_QdyP alert alert--success"><div class="admonitionHeading_yjbo"><span class="admonitionIcon_SgN0"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skip the admin panel</div><div class="admonitionContent_cglA"><p>If you use Claude Code, the <a class="" href="https://docs.bffless.com/features/claude-code-plugin/">BFFless plugin</a> ships a <code>traffic-splitting</code> skill that knows this whole configuration shape — weights, sticky sessions, query-param rules, alias naming. It pairs with the <a href="https://docs.bffless.app/features/mcp-server/" target="_blank" rel="noopener noreferrer" class="">BFFless MCP server</a>, which is what actually executes the API calls — the plugin teaches Claude <em>how</em> to configure things, the MCP server gives it the tools to do it. You'll need both.</p><p>Connect the MCP server once (see <a href="https://docs.bffless.app/features/mcp-server/#setup" target="_blank" rel="noopener noreferrer" class="">MCP Server — Setup</a> for the API key and config), then install the plugin:</p><div class="language-text codeBlockContainer_TblA theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_etv3"><pre tabindex="0" class="prism-code language-text codeBlock_hwC8 thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_tzmA"><span class="token-line" style="color:#F8F8F2"><span class="token plain">/plugin marketplace add bffless/claude-skills</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">/plugin install bffless</span><br></span></code></pre></div></div><p>Then just describe the test: <em>"split landing.clientname.com 34/33/33 across landing-main, landing-headline-outcome, and landing-headline-urgency, sticky sessions on, with ?version= query-param rules for each."</em> Claude handles the API calls. Source: <a href="https://github.com/bffless/claude-skills/tree/main/plugins/bffless/skills/traffic-splitting" target="_blank" rel="noopener noreferrer" class="">claude-skills/plugins/bffless/skills/traffic-splitting</a>.</p></div></div>
<h3 class="anchor anchorTargetStickyNavbar_wpU8" id="3-read-the-cookie-and-attribute-conversions">3. Read the cookie and attribute conversions<a href="https://docs.bffless.com/blog/ab-testing-landing-pages/#3-read-the-cookie-and-attribute-conversions" class="hash-link" aria-label="Direct link to 3. Read the cookie and attribute conversions" title="Direct link to 3. Read the cookie and attribute conversions" translate="no">​</a></h3>
<p>Your frontend doesn't need to know <em>which</em> variant it is at build time. At runtime, read the cookie:</p>
<div class="language-javascript codeBlockContainer_TblA theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_etv3"><pre tabindex="0" class="prism-code language-javascript codeBlock_hwC8 thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_tzmA"><span class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">function</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">getVariant</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> match </span><span class="token operator">=</span><span class="token plain"> </span><span class="token dom variable" style="color:rgb(189, 147, 249);font-style:italic">document</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token property-access">cookie</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token method function property-access" style="color:rgb(80, 250, 123)">match</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token regex regex-delimiter">/</span><span class="token regex regex-source language-regex group punctuation" style="color:rgb(248, 248, 242)">(?:</span><span class="token regex regex-source language-regex anchor function" style="color:rgb(80, 250, 123)">^</span><span class="token regex regex-source language-regex alternation keyword" style="color:rgb(189, 147, 249);font-style:italic">|</span><span class="token regex regex-source language-regex">; </span><span class="token regex regex-source language-regex group punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token regex regex-source language-regex">__bffless_variant=</span><span class="token regex regex-source language-regex group punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token regex regex-source language-regex char-class char-class-punctuation punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token regex regex-source language-regex char-class char-class-negation operator">^</span><span class="token regex regex-source language-regex char-class">;</span><span class="token regex regex-source language-regex char-class char-class-punctuation punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token regex regex-source language-regex quantifier number">*</span><span class="token regex regex-source language-regex group punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token regex regex-delimiter">/</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword control-flow" style="color:rgb(189, 147, 249);font-style:italic">return</span><span class="token plain"> match </span><span class="token operator">?</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">decodeURIComponent</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">match</span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token number">1</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token operator">:</span><span class="token plain"> </span><span class="token keyword null nil" style="color:rgb(189, 147, 249);font-style:italic">null</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></span></code></pre></div></div>
<p>Then attach that value to every conversion event your analytics tool sends. GA4 example:</p>
<div class="language-javascript codeBlockContainer_TblA theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_etv3"><pre tabindex="0" class="prism-code language-javascript codeBlock_hwC8 thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_tzmA"><span class="token-line" style="color:#F8F8F2"><span class="token dom variable" style="color:rgb(189, 147, 249);font-style:italic">document</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token method function property-access" style="color:rgb(80, 250, 123)">querySelector</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token string" style="color:rgb(255, 121, 198)">'#cta'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token method function property-access" style="color:rgb(80, 250, 123)">addEventListener</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token string" style="color:rgb(255, 121, 198)">'click'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token arrow operator">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token function" style="color:rgb(80, 250, 123)">gtag</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token string" style="color:rgb(255, 121, 198)">'event'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'cta_click'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token literal-property property">variant</span><span class="token operator">:</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">getVariant</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><br></span></code></pre></div></div>
<p>Same one-liner works for Mixpanel, Amplitude, Pendo, PostHog, or whatever internal pipeline you pipe events to. Segment by <code>variant</code> in your dashboard and you have per-variant conversion rates without paying for a second analytics product.</p>
<h3 class="anchor anchorTargetStickyNavbar_wpU8" id="4-pick-a-winner-and-roll-out">4. Pick a winner and roll out<a href="https://docs.bffless.com/blog/ab-testing-landing-pages/#4-pick-a-winner-and-roll-out" class="hash-link" aria-label="Direct link to 4. Pick a winner and roll out" title="Direct link to 4. Pick a winner and roll out" translate="no">​</a></h3>
<p>When the test is conclusive, flip the winner to 100% in the traffic splitting panel. Instant rollback, no redeploy. Keep the losing aliases around as a history of what didn't work, or delete them.</p>
<h2 class="anchor anchorTargetStickyNavbar_wpU8" id="what-you-get-that-the-builders-dont">What you get that the builders don't<a href="https://docs.bffless.com/blog/ab-testing-landing-pages/#what-you-get-that-the-builders-dont" class="hash-link" aria-label="Direct link to What you get that the builders don't" title="Direct link to What you get that the builders don't" translate="no">​</a></h2>
<p>This isn't a full replacement for Unbounce or Instapage — BFFless is not a drag-and-drop page builder, and if your team doesn't have a dev who can ship HTML, you probably want one of those tools. But if you <em>do</em> have a dev, you get a handful of things that stop being hypothetical:</p>
<ul>
<li class=""><strong>Any framework, any CI.</strong> React, Astro, plain HTML, an internal design system — whatever builds into a static folder works.</li>
<li class=""><strong>Branches are variants.</strong> Git is your variant manager, code review is your QA process, <code>git revert</code> is your rollback.</li>
<li class=""><strong>No per-page pricing.</strong> Experiments aren't billable events. Run twenty at once if you want to.</li>
<li class=""><strong>Client preview links for free.</strong> <code>?version=</code> rules give every stakeholder a stable URL per variant without another plan tier.</li>
<li class=""><strong>Static-fast by default.</strong> LCP stays wherever your build tool puts it. Quality Score benefits compound into lower CPCs over the life of the account.</li>
<li class=""><strong>Self-hostable.</strong> If your client's legal team has opinions about where landing page data lives, BFFless runs on your own infra.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_wpU8" id="try-it">Try it<a href="https://docs.bffless.com/blog/ab-testing-landing-pages/#try-it" class="hash-link" aria-label="Direct link to Try it" title="Direct link to Try it" translate="no">​</a></h2>
<p>There's a live traffic-splitting demo running at <a href="https://demo.docs.bffless.app/" target="_blank" rel="noopener noreferrer" class="">demo.docs.bffless.app</a> — open it in an incognito window a few times and watch the button color change. Our own homepage at <a href="https://bffless.app/" target="_blank" rel="noopener noreferrer" class="">bffless.app</a> is currently running a four-way headline test using exactly the pattern above.</p>
<p>If you want to go deeper, the <a class="" href="https://docs.bffless.com/recipes/ab-testing/">A/B testing recipe</a> walks through the GitHub Actions setup, analytics integrations for GA4, Mixpanel, and Pendo, and the full cookie behavior. The <a class="" href="https://docs.bffless.com/features/traffic-splitting/">traffic splitting reference</a> covers weights, rules, and canary rollouts in detail.</p>
<p>Spin up an instance, point a client's landing page at it, and see what your experiment velocity looks like when variants are free.</p>
<div style="margin:4rem 0 2rem;padding:2rem 1.75rem 1.25rem;border-radius:4px;background:var(--ifm-color-emphasis-100);border:1px solid var(--ifm-color-emphasis-200)"><h4 class="anchor anchorTargetStickyNavbar_wpU8" id="the-button-is-part-of-the-experiment">The button is part of the experiment<a href="https://docs.bffless.com/blog/ab-testing-landing-pages/#the-button-is-part-of-the-experiment" class="hash-link" aria-label="Direct link to The button is part of the experiment" title="Direct link to The button is part of the experiment" translate="no">​</a></h4><p>The <strong>Like</strong> button below is the CTA for this post — and we're tracking clicks by variant in both the BFFless backend and Google Analytics. Same mechanism this post describes, pointed at the post itself. If it was useful, the button is how we hear it.</p><p>Follow-up with the numbers in a couple of weeks — which variant won and by how much.</p><div style="display:flex;justify-content:center;margin:1.5rem 0 0.5rem"><button type="button" disabled="" aria-pressed="false" style="appearance:none;border:1px solid var(--ifm-color-primary);background:var(--ifm-color-primary);color:var(--ifm-color-white, #fff);padding:1rem 2.5rem;border-radius:4px;font-size:1.125rem;font-weight:600;letter-spacing:0.01em;cursor:default;opacity:0.7;box-shadow:0 1px 2px rgba(0, 0, 0, 0.12);transition:background 0.15s, color 0.15s, box-shadow 0.15s, border-color 0.15s">♥ Like this post</button></div></div>]]></content:encoded>
            <category>Features</category>
            <category>CRO</category>
        </item>
    </channel>
</rss>