A modern content site wants two contradictory things: the blazing speed and cacheability of a static site, and the relevance of content tailored to who is asking. This chapter shows how to reconcile them. We cover audience segmentation models, where personalization decisions should physically run (origin vs. edge vs. client), how A/B and multivariate testing and feature flags fit a content workflow, and how to inject dynamic blocks into an otherwise-static page without shredding your CDN cache, your Core Web Vitals, or your SEO. The throughline: keep the page mostly static and public, isolate the small dynamic surface, and decide per fragment — not per page — what gets personalized.
A static page is a single artifact that every visitor receives byte-for-byte, so a CDN can serve it from the edge in single-digit milliseconds. The instant you vary the page by viewer, you have, in cache terms, N pages instead of one — and a cache that splits into thousands of variants stops being a cache. The art of personalization on a static-first stack is to shrink the personalized surface to the smallest possible fragment and let everything else stay public and shared.
Three architectural truths anchor the rest of the chapter:
Vary, a normalized cookie, a geo bucket, or a path). If it varies on something not in the key, you serve the wrong person's content — a real and recurring incident class.Personalization is a function of a segment (or a continuous signal) applied to a . Before any rendering question, you need a segmentation model, and the signals available differ wildly by cost, latency, and privacy weight.
| Signal | Where it lives | Latency to read | Privacy / consent weight | Cache impact |
|---|---|---|---|---|
Geo / country (request.cf.country, Vercel x-vercel-ip-country) | Edge request metadata | Microseconds | Low (IP-derived, no consent needed for basic geo in most regimes) | Bucket into few values → cache per bucket |
| Device / viewport hint | User-Agent/Client Hints | Microseconds | Low | 2–3 buckets |
Language (Accept-Language) | Request header | Microseconds | Low | Few buckets |
| Returning vs. new visitor | First-party cookie | Microseconds (edge) | Low–medium | 2 buckets |
| Explicit preference (theme, region opt-in) | First-party cookie / KV | Microseconds–ms | Low (user-set) | Small N |
| A/B test bucket | First-party cookie + hash | Microseconds | Low | N = variants |
| Logged-in user profile / CRM segment | Origin DB / CDP | 10–100ms | High (PII, consented) | Do not cache per-user; stream |
| Behavioral / ML "audience" | CDP / analytics (PostHog, Segment, RudderStack) | ms–seconds (precomputed) | High | Resolve as fragment |
Two segmentation philosophies dominate in 2026:
Practical rule: every segment that affects what bytes a user receives must be derivable from data you are allowed to use and that you can fit into a cache key. A CMP (OneTrust, Cookiebot, Osano) gates which signals are even readable; Google Consent Mode v2 is the de-facto integration point if you also run Google tags.
This is the single most consequential choice, because it dictates your caching story.
The page ships static and identical; JavaScript reads cookies/local profile and swaps DOM after load.
A middleware/Worker runs at the CDN PoP, inspects the request, and either rewrites the path, sets a header that enters the cache key, or assembles fragments — all before the user gets bytes.
rewrite()s / to /_variants/eu-returning which is itself statically cached.request.cf exposes country, region, city, ASN with zero added latency; combine with a small KV map of flags; you can set a custom cache key on the cf object so a personalized response still caches per-bucket. Cloudflare's own "location-based personalization at the edge" pattern is the canonical reference.The application server renders per request. Powerful (full DB access) but slow and uncacheable per user — the thing static-first is trying to avoid. Reserve for authenticated app surfaces, not content pages.
The most important development for this chapter is Next.js 16 (October 2025), which shipped Cache Components and graduated Partial Prerendering (PPR) from experimental to stable. PPR serves a static shell (prerendered, CDN-cached, instantly visible) with dynamic "holes" — anything reading cookies(), headers(), or live data — wrapped in <Suspense> and streamed in parallel after the shell. This is partial-page caching reborn at the framework level: the product copy, images, and schema are static; the price, stock, and "for you" rail stream into holes. Per the Next.js docs, "PPR should be the default rendering strategy going forward" for pages with a stable shell and small dynamic regions.
This is the modern, framework-native answer to the decades-old Edge Side Includes (ESI) technique (Akamai/Fastly/Varnish), where a publicly cached page contains <esi:include> tags pulling privately cached or live fragments assembled at the edge. ESI is still alive (Fastly, Adobe Experience Manager, LiteSpeed) and is the right tool when you are not on a React framework. Caveat from the Fastly/Walmart writeups: ESI tags are processed sequentially at the CDN, so many low-TTL includes add latency — keep includes few and cache each fragment aggressively.
| Approach | Cache story | Flicker/CLS | SEO | Best for |
|---|---|---|---|---|
| Client-side swap | 1 page for all | High risk | Poor (not in HTML) | Below-fold, post-consent rails |
| Edge rewrite to variant | N bucketed pages | None | Good | Geo/device/test arms on static sites |
| Edge fragment assembly (ESI / Workers) | Public shell + cached fragments | None | Good | Non-React static stacks |
| PPR / Cache Components (Next.js 16) | Static shell + streamed holes | None | Good (shell in HTML) | Per-user data on framework sites |
| Full SSR per request | Uncacheable per user | None | Good | Authenticated app, not content |
Feature flags decouple deploy from release and are the substrate for both progressive rollouts and experimentation. The 2026 landscape has consolidated around an open standard plus a few platforms.
OpenFeature is a vendor-agnostic, CNCF-incubating API for flag evaluation (Web SDK v1 shipped 2024). It lets you write client.getBooleanValue('new-hero', false, evalContext) once and swap providers (LaunchDarkly, flagd, GrowthBook, self-hosted) underneath — avoiding code-level lock-in. The companion OFREP (OpenFeature Remote Evaluation Protocol) standardizes network evaluation. flagd is the CNCF reference backend: a tiny daemon with a Unix philosophy, runnable as a sidecar, in-process engine, or central service — no UI, configured by files/CLI.
| Tool | Model | Edge/local eval | Experimentation built-in | Pricing posture (2026) | Notes |
|---|---|---|---|---|---|
| GrowthBook | Open-source, cloud or self-host | Yes — 24+ SDKs, "zero network calls", edge SDK | Yes — any flag → A/B test in one click, Bayesian stats engine over your warehouse | Free self-host indefinitely; cloud tiers | Strongest OSS combo of flags + experimentation + analytics |
| PostHog | Cloud / self-host | Local eval supported | Yes — flags + experiments + product analytics in one | Usage-based: 1M flag requests/mo free, then $0.0001/req dropping to $0.00001 at 50M+ | Good all-in-one; flags piggyback on analytics |
| Vercel Flags SDK + Edge Config | Edge-native | Yes — flag definitions synced into Edge Config, evaluated at PoP | Pairs with Statsig/LaunchDarkly | Edge Config included in Vercel plans | Tight Next.js/middleware integration; Toolbar for overrides |
| LaunchDarkly | Commercial | Yes — Edge Config/Relay sync | Yes (experimentation add-on) | Enterprise pricing | Mature, governance/audit heavy |
| Statsig | Commercial / free tier | Yes | Yes — strong stats engine | Generous free tier | Experimentation-first |
| flagd / OpenFeature | OSS, self-host | Yes — sidecar/in-process | No (eval only) | Free | Bring-your-own UI; standard-compliant |
| Unleash | OSS, self-host | Yes (Edge/Proxy) | Limited | Free OSS + paid | Popular self-host alternative |
For a content CMS, the decisive features are: local/edge evaluation (so a flag check is a memory read, not a network round-trip that blocks rendering), sticky bucketing (a user stays in the same arm across visits via a hashed ID), and OpenFeature compatibility (future-proofing). GrowthBook and PostHog are the strongest open, self-hostable defaults; Vercel's Flags SDK is the least-friction choice if you're already on Vercel + Edge Config.
Experimentation is personalization's measured cousin: deterministically assign a visitor to an arm, serve that arm, and measure a metric.
Assignment that is cache-friendly:
exp_uid) with the experiment salt → arm. Deterministic, no server round-trip, sticky across visits./lp → /lp/b) or set a response header/cookie that you add to the cache key. Now you cache one page per arm, not per user.Multivariate (MVT): testing combinations of multiple elements multiplies arms (3 headlines × 2 heroes × 2 CTAs = 12 cells) and therefore cache variants and required traffic. On a static-first site, prefer few-cell MVT or sequential A/B; reserve full factorial MVT for high-traffic pages where the cache-variant count is tolerable.
SEO rules (straight from Google Search Central's "A/B Testing Best Practices for Search," reaffirmed 2025):
rel="canonical" on alternate/variant URLs pointing to the original; Google prefers this over noindex because it matches intent.302 (temporary), not 301, for test redirects so you don't permanently move link equity to a variant.Concrete, ordered by how much they cost the cache:
country/device, picks one of a few prerendered banners, rewrites or string-replaces a placeholder. Cache per bucket. Zero flicker.<Suspense>; the shell is static-cached, the hole streams. Framework handles cache boundaries.<esi:include src="/fragments/recommendations?seg=eu-returning"> in a long-TTL page; the fragment endpoint is itself cached per segment./api/recos?uid=… and render. Acceptable because it's non-critical and out of the SEO/CLS path.Caching hygiene that prevents incidents:
Cache-Control: public, s-maxage=… only on truly shared fragments; mark per-user fragments private, no-store.Vary (e.g., Vary: X-Device-Class) or a normalized custom cache key so variants don't collide. Strip high-cardinality cookies/headers from the cache key — an un-normalized Cookie header is the #1 cause of cache fragmentation and accidental cross-user leakage.no-store and never let it reach a shared cache.302 for any test redirect, no UA branching, tests time-boxed.rel="canonical" to the original, 302 not 301, time-box tests, and keep the JS canonical identical to the HTML canonical.request.cf geo + KV + custom cache keys for bucketed personalization.