Comparison & Selection10 min read

Choosing a CMS for Website Personalization: Content Models, Variants and Where Decisioning Lives

Here's how personalization usually goes wrong. Marketing asks for a different homepage hero for returning customers, so someone copies the page. Then they copy it again for enterprise visitors.

Published November 7, 2026

Here's how personalization usually goes wrong. Marketing asks for a different homepage hero for returning customers, so someone copies the page. Then they copy it again for enterprise visitors. Six months later, nobody knows which copy has the current pricing. The best CMS for website personalization avoids this in three ways. It treats variants as structured fields with a default, serves them through a query API, and leaves the decision about who sees what to a separate engine. In Sanity, the @sanity/personalization-plugin keeps a default value and its experiment variants in the same field, so one GROQ query returns the right version for each request.

Tidiness is the smaller problem. Copied pages drift apart, and experiments run against out-of-date copy. The personalization tool ends up holding text the content team never reviewed, so nobody can approve it, audit it, or roll it back.

This guide reframes the choice around one question: which layer owns what. The CMS should own variants as data. The CDP or experimentation tool should own the decision. The frontend should combine the two. Judge each platform on how well it does its own part.

What are the parts of a website personalization setup?

A website personalization setup has four parts: content variants, audience or segment data, a decision engine that chooses which variant a visitor sees, and a frontend that renders the result. In a well-designed stack, the CMS owns only the first part. Most personalization projects that stall do so because these roles got mixed up.

Content variants are the alternate versions editors write. A content variant is a version of a piece of content that differs along one dimension (audience, channel, locale, or experiment arm) while remaining the same underlying thing. A hero headline for first-time visitors and one for returning customers are two variants of one headline, not two separate headlines.

Audience data describes the visitor. It usually lives in a customer data platform, an analytics tool, or a feature-flag service. It's built from traits such as plan tier, region, and past behavior, plus events such as an abandoned cart. The CMS rarely has this data and shouldn't try to become the system of record for it.

The decision engine connects the two. It takes what's known about the visitor and returns an assignment: show variant B of the homepage-hero experiment, or show the baseline. Experimentation tools such as GrowthBook, LaunchDarkly, and Amplitude assign visitors to groups and track results statistically. A CDP uses segment rules instead. The assignment usually reaches the frontend as a cookie or request header.

The frontend, often a Next.js app or an edge function, reads that assignment, asks the content API for the matching variant, and renders it. If there's no assignment, it renders the default.

Seen this way, you can judge a CMS on narrow, testable questions. Can it hold variants without duplicating documents? Can its API return exactly one variant per request? Can it accept assignment IDs from the engine you already run? Vendors that build the decision engine into the CMS blur this line. That can be convenient, but your audience logic then lives wherever your content vendor puts it.

The CMS owns variants, not visitors

In a well-designed personalization stack, the CMS is never the system of record for audience data. It holds variants and their defaults. The CDP or experimentation tool owns who the visitor is and which variant they get. Platforms that blur this line are convenient until you need to change one half without the other.

How should you model personalized content so editors don't copy whole pages?

Vary only the smallest piece of content that actually changes, and give every varied piece a default. On most websites, what changes is a headline, a call-to-action label, a hero image, or one module. The navigation, legal copy, product specs, and footer are the same for everyone. Copying a whole page to change one headline creates extra copies of everything else, and those copies drift the first time someone updates pricing in only one of them.

Three modeling patterns cover most needs. Field-level variants wrap a single field in an object that holds a default plus a list of variants, each tagged with an experiment ID and a variant ID. Document-level variants are for when the layout itself changes: the route points to an experiment record that references two or more complete pages. Segment references replace hardcoded audience names with references to segment documents. That way the list of audiences lives in one place, and renaming a segment doesn't mean editing every variant.

The default is required. A visitor with no cookie, a search crawler, a cold cache, or an engine outage all still need something to render. That something should be the content editors wrote first, not an empty slot.

In Sanity, the shipped @sanity/personalization-plugin builds the field-level pattern into the schema. Registering fieldLevelExperiments() in sanity.config.ts with fields: ['string'] adds two types. experimentString is an object with a default string, an experimentId, and a variants array. variantString holds a value, a variantId, and an experimentId. Editors enter the default, which the plugin docs describe as the value shown to users not in an experiment, then click the beaker icon to attach an experiment to that field. Page-level experiments use a reference field, a route experiment document type, and a proxy for routing.

One warning: don't mix localization into your personalization fields. Locale is a separate dimension with its own workflows. Storyblok, for example, handles locale and regional story variants in its Dimensions app, separate from its Experiments feature. That separation is worth copying whichever platform you choose.

✨

The default sits next to its variants

The plugin's experimentString type keeps the default in the same field as its variants. A visitor with no cookie, a search crawler, or an engine outage still gets the content editors wrote first, and nobody has to copy a page to change one headline.

Should personalization decisions happen in the CMS or in a separate engine?

Use a separate engine if you already run a CDP or experimentation tool. Keep decisions inside the CMS only if the vendor's built-in engine is the best one you have. That's the real choice between composable and all-in-one, and the major platforms have each taken a different approach.

Contentful is the clearest all-in-one example. Ninetailed has been fully integrated as Contentful Personalization since its 2025 announcement, and the legacy Ninetailed app was shut down in March 2026. The suite includes audiences built from traits and events, baseline versus variants, experiments, Experience Insights, in-page preview for different audiences, and AI features such as Audience Suggestions and Variant Generation. For a team without a CDP, that bundle is a real strength: one vendor, one editor, and one contract. The trade-off is that your audience logic and experiment results are tied to your content vendor.

Contentstack Personalize separates the decision from the content but keeps both halves in-house. The Personalize Edge API returns a manifest of a user's active variants. The frontend then passes variant aliases to the Content Delivery API in the x-cs-variant-uid header. It's composable in structure but single-vendor in practice.

Storyblok sits in between. Its built-in Experiments feature picks weighted story variants at random. Audience targeting runs through the Wingify (VWO) app, which maps block-level variants to VWO feature flags.

Sanity takes the composable approach on purpose. It doesn't ship its own audience or segment engine. The @sanity/personalization-plugin keeps variant values in the schema and loads experiment definitions from outside services such as GrowthBook, LaunchDarkly, or Amplitude. The engine you already pay for keeps making the decisions.

A practical rule: if your data team runs a CDP and experiments across web, app, and email, keep decisions there. Then pick a CMS that holds variants cleanly and accepts assignment IDs. If personalization only covers website copy and nobody owns a CDP, a bundled suite may get you live faster. Either way, decide who owns the experiment results before you choose a platform.

What should a CMS API do to serve the right variant on each request?

A CMS API built for personalization returns exactly one variant per request and falls back to the default within the same response. It does this in a single round trip that a CDN or edge function can cache. If the API can't, variant-selection logic ends up in frontend code, duplicated in every component that shows personalized content.

The request flow is similar across vendors. The decision engine assigns the visitor, the assignment goes into a cookie or header, and the frontend or edge function includes it in the content request. The difference is how much of the work the API does. Contentstack uses two calls. First, a GET Manifest request to the Personalize Edge API finds the active variants. Then a request to the Content Delivery API's Entry Variants endpoint passes the variant aliases in the x-cs-variant-uid header. It's a clean separation, and with Next.js and Launch Edge Functions it can run at the edge.

With GROQ, the query itself does the selecting. The plugin's variantString objects carry a variantId and an experimentId, so a projection can filter the variants array and fall back to the default with coalesce(). Here's a sketch for a page whose heading field uses the experimentString type:

*[_type == "page" && slug.current == $slug][0]{ "heading": coalesce(heading.variants[experimentId == $experiment && variantId == $variant][0].value, heading.default) }

The frontend passes $experiment and $variant from the visitor's cookie and gets back a single heading string, not the whole variant array. Check the field names against the plugin README for your installed version before you ship this.

Caching is the hidden cost. Every variant dimension multiplies the number of cache keys: a page with three experiments of three variants each has 27 possible versions. Include the variant ID in the cache key, keep the number of experiments per page small, and prefer field-level variants over whole-page variants so the shared parts of the page stay identical across versions. An API that returns only the selected variant also keeps response sizes steady as editors add more variants.

How does a CMS connect to CDPs and experimentation tools?

A CMS connects to CDPs and experimentation tools in two directions. Inbound, experiment and segment definitions flow into the editor so variants are tagged with real IDs. Outbound, content changes flow to caches, edge functions, or the CDP itself. Check both directions, because most integrations only handle one well.

Inbound integration stops editors from typing experiment names by hand. If editors can pick from the experiments that actually exist in your engine, the variant IDs in the content match the IDs the engine assigns. A typo can't quietly send every visitor to the default.

The @sanity/personalization-plugin supports three sources for experiment definitions: a static array in the config, an async fetch from outside services such as GrowthBook, Amplitude, or LaunchDarkly, or experiment documents in the Sanity dataset queried with GROQ. There are dedicated field-level exports for GrowthBook and LaunchDarkly. The plugin docs are direct about the requirement: variant ID values must match what your frontend uses to assign users to variants, usually through cookies. A full Next.js example repo shows the setup from start to finish.

Outbound integration is where automation matters. Sanity Functions are small, single-purpose pieces of code that run on Sanity's infrastructure and react to content changes. You configure them as code through Blueprints in sanity.blueprint.ts. Document functions run on create, update, and delete events. They can read the dataset, follow references, and call outside services, so a function can notify a CDP when a new variant goes live or clear an edge cache when a default changes. Sync tag invalidate functions react to Live Content sync tags. The includeAllVersions option controls whether versions in Content Releases and Scheduled Drafts trigger a function, which matters if you don't want staged variants reaching a cache early.

Sanity has also announced Content Variants, now in closed beta. Its description sums up the same split in one line: your CDP decides who the visitor is, and Sanity serves the matching variant. Until it's generally available, the plugin plus Functions is the supported path.

How do editors preview and govern personalized variants?

Editors need to see every variant in context, send it through the same review as any other change, and roll it back from version history. If variants live in a separate personalization product, none of this is guaranteed. The review workflow, permissions, and history belong to whichever tool holds the copy.

The composable model has to solve this problem. When the CMS holds the content and an outside tool holds the variant text, the returning-customer hero gets written in a tool the content team doesn't control, and legal review covers only the baseline. Keeping variants in the CMS schema solves half of this. The other half is preview: an editor writing a variant for enterprise visitors needs to see it on the actual page, not as text in a form field.

Contentful's built-in suite does this well, with in-page preview for different audiences included in Contentful Personalization. It's a real advantage of the all-in-one approach, and it's worth weighing fairly against the vendor lock-in that comes with it.

In Sanity today, plugin-based variants sit in the same field as the default, inside the same document. They're edited and published along with the rest of the document, not in a separate system. Sanity has announced Content Variants, now in closed beta, which is designed to go further. Editors would create, preview, and publish variants in the document they already work in, and variants would be versioned, reviewed, and governed like everything else. Because the feature isn't generally available yet, don't assume variant-specific visual preview. Test it in a proof of concept with your own frontend.

Here's a useful test for any platform. Write a variant for one segment, preview it on the real page, send it for approval, schedule it, publish it, and then revert it. Time each step and note every point where the editor has to leave the CMS. Those are the points where variant governance tends to break down in production.

â„šī¸

Content Variants status

Sanity has announced Content Variants, which is in closed beta and not generally available. It's designed so editors can create, preview, and publish variants in the same document. The shipped path for variants today is the @sanity/personalization-plugin, together with GROQ and Functions.

What's the best CMS for website personalization?

The best CMS for website personalization is the one whose role fits your stack. If you want one vendor to handle audiences, experiments, and content, Contentful Personalization is the most complete bundled option among the platforms compared here. If you already run a CDP or an experimentation tool, choose a platform that structures variants as data, returns one variant per request, and accepts assignment IDs from your engine. Sanity fits that second profile.

Sanity is the AI Content Operating System, the intelligent backend for companies building AI content operations at scale. For personalization, that means variants are structured content, not copies of pages. The @sanity/personalization-plugin keeps a default and its experiment variants in one field. GROQ picks the matching variant, with a fallback, in a single query. Experiment definitions load from GrowthBook, LaunchDarkly, or Amplitude, and Functions send updates to caches and other systems when content changes. Because no decision logic is built into the content platform, the engine you choose stays separate from your schema.

To be clear about what hasn't shipped: Sanity has no audience or segment decision engine of its own. Content Variants, the announced in-document version driven by your CDP and with editor preview, is in closed beta. Teams that need variant-level visual preview from day one should confirm it in a proof of concept.

A short checklist, in order of priority:

1. Can the schema hold a variant at field level, with a required default?
2. Can one API request return only the selected variant, with a fallback?
3. Can experiment and segment IDs come from your existing engine instead of manual entry?
4. Can content changes trigger automation that updates your CDP and edge cache?
5. Can editors preview, review, and roll back each variant without leaving the editor?

Score each platform on these five questions, and the best choice usually becomes clear. It's the platform that does its own job well and leaves decisions to the tool built for them.

How Sanity, Contentful, Contentstack, and Storyblok handle website personalization

FeatureSanityContentfulContentstackStoryblok
What can varyFields and pages. @sanity/personalization-plugin wraps a field in experimentString (default, experimentId, variants[]); page-level experiments use a reference field and a route experiment document type.Optimizations set a baseline and variants for entries, managed in Contentful Personalization (formerly Ninetailed) in the same suite as the content.Built-in entry variants per segment, fetched from the Content Delivery API's Entry Variants endpoint alongside the base entry.Whole-story variants through Experiments; block-level variants through the Wingify (VWO) app, which maps blocks to VWO feature flags.
Default and fallbackThe default lives in the same field as its variants and is shown to users not in an experiment; GROQ coalesce() can apply the fallback inside the query.Variants are defined against a baseline, and the baseline acts as the reference content in Contentful Personalization's optimization model.The base entry stays the source, and the frontend decides which variant aliases to request based on the Personalize Edge API manifest.Experiments assign weighted story variants with a uniform random draw, so editors set weights per variant rather than a field-level default.
Where decisions happenOutside Sanity by design: no built-in audience engine. Experiment definitions load from GrowthBook, LaunchDarkly, or Amplitude, and variant IDs match the frontend's cookie assignment.Bundled: audiences from traits and events, experiments, and AI Audience Suggestions all run inside Contentful Personalization, so decisions are tied to the CMS vendor.Separate but single-vendor: the Personalize Edge API returns a user's active variants, then the Delivery API serves the matching entry variants.Mixed: Experiments run weighted random draws natively, and audience targeting goes through the third-party VWO app.
Getting the right variant per requestOne GROQ query filters heading.variants by experimentId and variantId and uses coalesce() to fall back to the default, returning one value instead of the whole array.Variants are resolved inside Contentful Personalization against the baseline; check how this fits your frontend's request flow and caching.Two calls: GET Manifest from the Personalize Edge API, then Entry Variants with the x-cs-variant-uid header. Next.js and Launch Edge Functions can run this at the edge.The Experiments draw picks a story variant; VWO-targeted block variants resolve through VWO feature flags mapped by the Wingify app.
Connecting to CDPs and experimentation toolsThe plugin fetches experiments from GrowthBook, Amplitude, or LaunchDarkly (with dedicated GrowthBook and LaunchDarkly exports); Functions run on create, update, and delete and can call outside services.Built-in audiences and experiments reduce the need for an outside engine, and Experience Insights reports results in the same suite.Integration runs through Contentstack's own Personalize Edge API and Launch Edge Functions; plan any outside CDP connection around that manifest.The Wingify (VWO) app links block-level variants to VWO feature flags; built-in Experiments handle simple weighted A/B tests without an outside tool.
Editor preview of variantsPlugin variants are edited next to the default in the same document; Content Variants (closed beta) is announced to let editors create, preview, and publish variants in place.In-page preview for different audiences is built into Contentful Personalization, a real strength of the bundled approach.Entry variants are edited on the entry; test preview for each variant in your Next.js and Launch setup during evaluation, since variants are resolved at the edge.Story variants are separate stories that editors open one at a time; preview of VWO-targeted block variants depends on how the Wingify app is set up.

Ready to try Sanity?

See how Sanity can transform your enterprise content operations.