Static sites and personalization have an obvious tension. Gatsby's whole value proposition is that every URL is a file built once and served from a CDN edge. Marketing's whole request is that the hero headline, the pricing table, or the regional phone number change per visitor — and that half the traffic sees variant B so someone can read a conversion number next quarter.
The bad answers are well known. Client-side variant swapping from a third-party script gives you a flash of original content, a Largest Contentful Paint regression, and a render-blocking tag you can never remove. Server-rendering every page to personalize a headline throws away the CDN. Building a separate Gatsby site per segment multiplies your build minutes by the number of segments.
The good answer in 2026 is to keep the pages static and move the decision to the edge: a small function in front of the CDN that reads a cookie or a header, picks a variant, and rewrites the request to a different pre-built static path. The HTML that reaches the browser is final, cacheable, and complete. This tutorial walks through that pattern on a Gatsby 5 site, including the parts that usually break: cache keys, SEO, analytics attribution, and knowing when to stop.
The shape of the pattern
Three pieces:
- Gatsby builds every variant as a real page.
/pricing/and/_variants/pricing-b/are both static HTML inpublic/. - An edge function intercepts the request to
/pricing/, decides A or B, and rewrites (not redirects) to the variant path. The URL in the address bar never changes. - The decision is sticky and observable — written to a cookie, echoed into a header, and read by your analytics layer so the experiment actually produces data.
Everything expensive happens at build time. The edge does an if.
Step 1: Generate variants at build time
Do not fork the page component. Make the variant a data input so the two builds cannot drift.
// gatsby-node.js
const path = require("path");
exports.createPages = async ({ graphql, actions }) => {
const { createPage } = actions;
const template = path.resolve("./src/templates/landing.jsx");
const { data } = await graphql(`
{
allLanding {
nodes {
slug
variants {
key
headline
subhead
ctaLabel
}
}
}
}
`);
data.allLanding.nodes.forEach((page) => {
page.variants.forEach((variant) => {
const isControl = variant.key === "a";
createPage({
path: isControl ? `/${page.slug}/` : `/_variants/${page.slug}-${variant.key}/`,
component: template,
context: {
slug: page.slug,
variantKey: variant.key,
canonicalPath: `/${page.slug}/`,
},
});
});
});
};
Two details matter more than they look.
canonicalPath is passed into every variant so the <link rel="canonical"> on /\_variants/pricing-b/ points at /pricing/. Without it you have published duplicate content at a crawlable URL, and Google will happily index the variant.
The variant paths live under a single /_variants/ prefix so you can exclude them from gatsby-plugin-sitemap and disallow them in robots.txt with one rule:
// gatsby-config.js
{
resolve: "gatsby-plugin-sitemap",
options: {
excludes: ["/_variants/**"],
},
},
{
resolve: "gatsby-plugin-robots-txt",
options: {
policy: [{ userAgent: "*", disallow: ["/_variants/"] }],
},
}
A noindex meta tag on variant pages is belt and braces; add it in the template when variantKey !== "a".
Step 2: Decide at the edge
The API differs by host but the logic does not. Netlify Edge Functions, Cloudflare Workers, and Vercel Edge Middleware all give you the request, a cookie jar, and a way to serve a different asset without changing the URL.
Netlify Edge Function version:
// netlify/edge-functions/experiment.js
const EXPERIMENT = "pricing-layout";
const VARIANTS = ["a", "b"];
const COOKIE = `exp_${EXPERIMENT}`;
export default async (request, context) => {
const url = new URL(request.url);
// Never bucket bots. Let crawlers see the control.
const ua = request.headers.get("user-agent") || "";
if (/bot|crawler|spider|headlesschrome|lighthouse/i.test(ua)) {
return context.next();
}
let variant = context.cookies.get(COOKIE);
let isNew = false;
if (!VARIANTS.includes(variant)) {
variant = Math.random() < 0.5 ? "a" : "b";
isNew = true;
}
const response =
variant === "a"
? await context.next()
: await context.rewrite(`/_variants/pricing-${variant}/`);
if (isNew) {
context.cookies.set({
name: COOKIE,
value: variant,
path: "/",
maxAge: 60 * 60 * 24 * 30,
sameSite: "Lax",
secure: true,
httpOnly: false, // analytics needs to read it
});
}
response.headers.set("x-experiment", `${EXPERIMENT}=${variant}`);
response.headers.append("Vary", "Cookie");
return response;
};
# netlify.toml
[[edge_functions]]
path = "/pricing"
function = "experiment"
context.rewrite is the important verb. A 302 to /_variants/pricing-b/ would expose the variant URL, break the canonical story, and hand your analytics two landing pages. A rewrite serves variant bytes under the original URL.
Step 3: The cache problem nobody warns you about
This is where most homegrown edge experiments quietly fail.
Your CDN caches by URL. You have just made the response for one URL depend on a cookie. If the edge layer runs behind the cache, or if the response is cacheable without a Vary: Cookie, the first visitor's variant gets pinned for everyone until the TTL expires — and your experiment silently becomes a coin flip that happened once at deploy time.
Rules that keep this honest:
- Make sure the edge function runs before the shared cache. On Netlify and Vercel, edge middleware runs on every request by design; on a bring-your-own CDN in front of it, verify with a cold
curl. - Send
Vary: Cookieon the experimented route only. Applying it site-wide destroys your hit rate for every other page. - Better still, normalize the cache key to just the experiment cookie if your platform supports cache key manipulation, so unrelated cookies (analytics IDs, consent state) do not fragment the cache into thousands of entries.
- Keep the experimented routes few. Personalizing your entire site at the edge is how a static site turns back into a dynamic one with worse observability.
Verify it, don't assume it:
for i in 1 2 3 4 5 6; do
curl -s -D - -o /dev/null https://example.com/pricing \
| grep -Ei "x-experiment|cf-cache-status|x-nf-request-id|age:"
done
Six cold requests with no cookie should not return six identical variants.
Step 4: Hydration must agree with the HTML
Gatsby hydrates the React tree over the server-rendered HTML. If the HTML came from the variant B build, the JavaScript bundle must also build variant B, or React logs a hydration mismatch and, worse, may re-render the control over the top of it — the flicker you were trying to avoid.
Because the variant is baked into pageContext at build time, this is already handled: /\_variants/pricing-b/ ships its own page-data.json with variantKey: "b", and the rewrite serves that page's data file too. The failure mode appears when someone "optimizes" by rewriting only the HTML and letting the original page-data.json load. Rewrite the whole page, not a fragment.
One related trap: Gatsby's client-side router. If a visitor arrives on the homepage and clicks an internal <Link to="/pricing/">, no edge function runs — the browser fetches /page-data/pricing/page-data.json directly and renders the control. For experiments on pages reachable via internal navigation you need either a plain <a href> for that link (forcing a document request) or a client-side read of the experiment cookie in the template to select variant content that was already shipped in the same bundle. Pick one and document it; mixing them produces numbers no one can explain.
Step 5: Make the data usable
An experiment that does not reach your analytics tool is a decorative coin flip.
// src/components/ExperimentTracker.jsx
import { useEffect } from "react";
export default function ExperimentTracker({ experiment, variant }) {
useEffect(() => {
if (!variant) return;
window.plausible?.("experiment_view", {
props: { experiment, variant },
});
window.dataLayer?.push({
event: "experiment_view",
experiment_id: experiment,
variant_id: variant,
});
}, [experiment, variant]);
return null;
}
Render it from the template with the values from pageContext, not from the cookie — pageContext is what the visitor actually saw. Then make sure your conversion event (form submit, call click) carries the same variant property, or you will be joining two datasets by timestamp later and regretting it.
For a consulting site the conversion is usually an inbound enquiry, which means the variant needs to survive into the form payload. A hidden field populated from the cookie is enough:
<input type="hidden" name="experiment_variant" value={variant} />
Step 6: Know when to stop
Two disciplines, both unglamorous.
Statistical: on a B2B site with a few thousand sessions a month and a conversion rate around 2%, detecting a 20% relative lift needs on the order of tens of thousands of sessions per arm. Most small-site "wins" called after ten days are noise. If your traffic cannot support the test, do not run an experiment — ship the version you believe in and measure the before/after with honest caveats.
Operational: every live experiment is a permanent branch in your content, your edge config, and your analytics schema. Give each one an owner and an end date in the code:
const EXPERIMENTS = {
"pricing-layout": { ends: "2026-04-30", owner: "growth" },
};
Fail the build when an experiment is past its end date. It is a five-line check in gatsby-node.js and it is the only thing that reliably stops a site from accumulating nine zombie variants that nobody dares delete.
What this buys you
You keep the static build, the CDN, and the Core Web Vitals scores that made Gatsby worth choosing. You get real server-side variant delivery with no flicker and no render-blocking third-party script. And the whole mechanism is about sixty lines of code you own, versioned with the site, reviewable in a pull request — rather than a tag manager container that nobody can diff.
The limits are real: it fits a handful of high-value pages, not a fully personalized site, and it needs an edge runtime in front of your CDN. If you need thousands of personalized segments with a self-service UI for non-developers, buy a platform. If you need to test three headlines on your pricing page without wrecking your performance budget, build this.
If you are weighing an experimentation setup on a Gatsby or static site — or you need someone to build the edge layer and the measurement plumbing so the numbers are trustworthy — get in touch and describe the pages you want to test.