How to personalize Sanity content based on visitor behavior


Sanity doesn't offer a personalization feature, and that's on purpose.

While some platforms hand you a personalization module with segmentation and a variant model, Sanity hands you structured content and gets out of the way. And they are explicit about the tradeoff.

Nobody can argue that their CMS is an excellent product. But there's a gap here.

Modeling content is the part Sanity makes easy. Delivering personalized content, which involves knowing who the visitor is, deciding which variant they get, keeping that decision stable across sessions, recording what they saw, and proving it worked, is the part you need to build.

This guide walks through how the standard pattern works, what it costs once you scale it, and the two most used ways teams close that gap.

Personalize your content based on the user context

Create and publish personalized experiences without replacing your CMS or over-relying on your developers.

What Sanity gives you

I'll try to be precise here, since the in-house path starts further along than people usually expect.

  • Structured variants

    Using tailored content means adding new fields to your document or an embedded object, and the same principle scales from a localized title to a full variant object. Although these examples are simple, their principles apply to any use case.

  • Audience segmentation

    You need to use GROQ to fetch the right variant at fetch time and use Sanity's content as fallback. This means the segmentation logic lives in the query (source code).

  • Modeling layer

    The @sanity/personalization-plugin lets users add A/B/n testing experiments to individual fields and page-level experiments. Then, editors enter a default value, assign an experiment, and enter variant-specific values for each variant. The plugin also ships with a direct GrowthBook integration, so editors can attach a GrowthBook experiment to a field from inside Studio, which makes it the default path most teams end up on.

So the content side is genuinely solved entirely inside Sanity CMS. What isn't solved is everything downstream of it.

The plugin's own documentation is candid about where the line sits. For experiments to work, your frontend must assign users to variants and pass the correct variant ID when querying content.

Let's start by walking through what that actually looks like for a single module.

Segment visitors based on conversion intent

How to use Sanity's plugin to personalize a module for a segment

The standard pattern has four steps:

  1. Define the segments
  2. Model a default plus overrides
  3. Resolve which segment the visitor belongs to
  4. Resolve the content at query time with a fallback

Here it is end-to-end for a hero module.

1. Define your segments

Segments need stable IDs, because the same string has to match in three places: your schema, your frontend logic, and your GROQ query.

12
// lib/segments.jsexport const SEGMENTS = ['enterprise', 'smb', 'returning', 'us', 'eu'];

Keep this list short at the start. Every segment you add multiplies the content editors have to maintain, since each personalized field needs a value per segment you want to differentiate.

2. Model the variant

Rather than duplicating documents, add a reusable object that holds a default value and an array of overrides. This is the pattern the personalization plugin generates for you, written out manually so you can see the shape.

123456789101112131415161718192021222324252627282930
// schemas/objects/personalizedString.jsexport default {  name: 'personalizedString',  type: 'object',  fields: [    {      name: 'default',      type: 'string',      title: 'Default',      validation: Rule => Rule.required(),    },    {      name: 'variants',      type: 'array',      title: 'Segment overrides',      of: [        {          type: 'object',          fields: [            {name: 'segment', type: 'string', title: 'Segment'},            {name: 'value', type: 'string', title: 'Value'},          ],          preview: {            select: {title: 'value', subtitle: 'segment'},          },        },      ],    },  ],};

Use it wherever a field should vary:

12345678910
// schemas/objects/hero.jsexport default {  name: 'hero',  type: 'object',  fields: [    {name: 'headline', type: 'personalizedString'},    {name: 'subheadline', type: 'personalizedString'},    {name: 'ctaLabel', type: 'personalizedString'},  ],};

Note what just happened: one hero module became three personalized fields.

That's the field-level granularity the plugin works at, and it's the detail that decides how much wiring you'll write next.

3. Resolve the visitor's segment

This is your code, not Sanity's. Read whatever signal you have and persist the result so the visitor doesn't flip segments between page loads.

123456789101112131415
// lib/resolve-segment.jsimport {cookies, headers} from 'next/headers';
export function resolveSegment() {  const stored = cookies().get('segment')?.value;
  if (stored) {    return stored;  }
  // Fall back to a request-level signal  const country = headers().get('x-vercel-ip-country');
  return country === 'US' ? 'us' : 'default';}

Two things bite here. The cookie has to be set server-side, since Safari's ITP caps client-set cookies at seven days and a visitor who returns after that gets re-segmented. And if a visitor matches more than one segment, let's say enterprise and returning and EU, you need a precedence rule, because the query below returns whichever override it finds first.

4. Query with a fallback

GROQ's coalesce() returns the first non-null argument, which makes the default-plus-override pattern a one-liner per field.

12345678910111213141516
*[_type == "page" && slug.current == $slug][0]{  "hero": {    "headline": coalesce(      hero.headline.variants[segment == $segment][0].value,      hero.headline.default    ),    "subheadline": coalesce(      hero.subheadline.variants[segment == $segment][0].value,      hero.subheadline.default    ),    "ctaLabel": coalesce(      hero.ctaLabel.variants[segment == $segment][0].value,      hero.ctaLabel.default    )  }}
12345678910111213
// app/[slug]/page.jsximport {client} from '@/lib/sanity';import {resolveSegment} from '@/lib/resolve-segment';import {PAGE_QUERY} from '@/lib/queries';
export default async function Page({params}) {  const page = await client.fetch(PAGE_QUERY, {    slug: params.slug,    segment: resolveSegment(),  });
  return <Hero {...page.hero} />;}

That works, and if you only ever personalize one module on one signal, this is a perfectly reasonable place to stop.

What this does not cover

3 fields, 3 coalesce() blocks, 1 segment parameter.

Add a second module, and you're maintaining the pattern in two queries. Add AB testing on top of segmentation, and each field needs an experiment ID as well as a variant ID.

In GrowthBook's own Sanity example, a product page testing 3 fields threads 6 parameters through the query: titleExperiment, titleVariant, descriptionExperiment, descriptionVariant, imageExperiment, and imageVariant.

More importantly, nothing above records what happened. You know which variant you rendered, but you have no idea whether it worked. There's no exposure event, no conversion attribution, and no statistics. In that same guide, the tracking component's TODO says to replace the console.log with your own event tracking, using tools like Google Analytics, Segment, or Amplitude.

So the four steps above are the first two layers of a six-layer system. Here's the rest of it.

Option 1: build the delivery stack

The best public illustration of this path is a Sanity talk by Simeon Griggs on personalizing a Next.js site, which frames the underlying problem well:

Before Jamstack took over, we served websites from servers that contained a lot of information about visitors as they came to each page. Now that we statically build pages and serve them on globally distributed CDNs, those static files are exactly the same. So how do you send a personalized piece of data to a user based on some information that we have?

Using localization as an example, his answer is very straightforward, and it's worth counting the moving parts.

A banner component fires off to an API route, which checks what country information is available (a manual override, request headers, or IP information). The GROQ query runs for banner documents whose country field matches the country variable passed into the query. Then, the component tracks a sticky state hook that logs to local storage the date when the banner was seen.

That's a component (1), an API route (2), a header/IP lookup (3), a query (4), and a client-side memory of what the visitor already saw (5). All of this for one banner, on one signal, with no experiment attached.

Now think about how to scale it. This is what the full stack looks like.

Layer 1, variant modeling

Either hand-rolled variant objects or the personalization plugin's generated field types. Manageable, and the plugin does real work here.

But note the granularity: it's field-level, which suits testing a single element like a headline, button text, or image where the page structure stays the same. Page-level, on the other hand, suits testing completely different page designs. A hero section that's one conceptual module is, in this model, four or five independent experiment fields you have to keep in sync.

Layer 2, audience definitions

You need to store the list of audiences and their respective variants somewhere.

One option is defining them as a static array in the plugin config and fetching them from an external service like GrowthBook or LaunchDarkly, the other is storing them as documents in your Sanity dataset.

Each option carries its own pros and cons:

  • Using static arrays means a deployment each time the marketing team wants to change the audience or experience definitions.
  • Using external services like GrowthBook or LaunchDarkly means an API key and a sync path.
  • Using a dataset avoids the deployment cycle but means maintaining a personalization CRUD surface inside your CMS.

Layer 3, identity and evaluation

This is the one you can't avoid, but will for sure underestimate: the variant IDs in your plugin configuration must match exactly what your frontend uses.

The recommended implementation is assigning segments via cookies on first visit and updating the cookie when the user switches segments. Getting this wrong won't break the website, but will cause a visitor to see personalized content that doesn't resonate with them.

Layer 4, query-time resolution

Every personalized field needs its experience ID and variant ID threaded through as query parameters exactly as in the example above. The wiring grows with every field you make dynamic.

Layer 5, tracking

Assignment isn't measurement. You need to record which visitor saw which variant, and this layer is left as an exercise: the tracking component's TODO says to replace the console.log with your own event tracking, using tools like Google Analytics, Segment, or Amplitude.

After that come metrics definitions, conversion attribution, and a statistics engine (like the Bayesian approach) to decide whether the impact is real.

Layer 6, segmentation

Once behavioral targeting goes past what you can read off a request header, you need a profile source (typically a CDP) and a contract between it and your content.

The recommended defense is more structure: treat the CMS as the contract for content variants and the CDP as the source of segment truth, maintain a lookup between CDP segment IDs and variant keys in the CMS, and let the frontend resolve which variant to render. Plus governance and a caching strategy, since cache strategy matters too: serve default content fast, then hydrate personalized elements client-side or at the edge.

All of that is sound advice. It's also a description of a system somebody has to own.

How much this costs

Six layers, at least three of them (assignment, tracking, segment sync) sitting outside your CMS entirely, each with its own failure modes and none of them your actual core product.

The ongoing cost isn't writing this once. It's that every new test touches multiple layers, and adding a segment means coordinating a CDP change, a lookup update, a query parameter, and a deploy.

Option 2: attach a dynamic layer

The alternative inverts the question. Instead of building a delivery pipeline and threading it through your schema, you leave the schema alone and make individual modules dynamic.

Croct sits alongside Sanity rather than in front of it. Your app fetches content from a slot. When an experience targets the visitor, Croct returns the dynamic variant. If the request fails, the component renders your original Sanity content as the fallback.

This makes the integration additive, since pages keep working exactly as they do today until you launch your first experience.

The same hero from the walkthrough above, with no personalizedString object, no segment parameter, and no coalesce():

123456789101112131415
// app/[slug]/page.jsximport {fetchContent} from '@croct/plug-next/server';
import {client} from '@/lib/sanity';import {PAGE_QUERY} from '@/lib/queries';
export default async function Page({params}) {  const page = await client.fetch(PAGE_QUERY, {slug: params.slug});
  const {content} = await fetchContent('home-hero', {    fallback: page.hero,  });
  return <Hero {...content} />;}

Your GROQ query goes back to fetching plain fields. The segment resolution, the precedence rules, and the cookie handling are gone, and the Sanity content becomes the fallback rather than the thing you thread parameters through.

Nothing about your CMS changes

Your schemas, documents, and editing workflow stay exactly as they are. Croct reads nothing from Sanity and writes nothing back to it, so you can adopt it one component at a time and roll back by removing a single fetchContent call.

No experimentString fields multiplying through your schema. No variant arrays in your documents. No experiment IDs stored next to your content. Your Sanity project on the day you launch your tenth experiment looks the same as it does today.

Integration scope

From your project, run the CLI. It detects your setup, installs the SDK, and wires the provider and middleware for you.

npx croct@latest init

Sanity's own official Next.js template has a version of this integration committed to it, and the README describes the scope precisely: adding Croct to an existing Sanity + Next.js project takes three small changes, with no restructuring of your content or pages required.

Modules, not fields

Unlike currently available plugins, you can personalize experiences at the component level rather than the field level. Croct replaces static module content with dynamic content, allowing you to manage everything directly on the UI while using Sanity content as a fallback.

A hero is one slot, not three or five experiment fields that have to be varied and queried in lockstep. When a marketer wants to test a different hero, they're changing one thing.

No third-party vendors

Layer 5 and Layer 6 above simply don't get built, since it comes with built-in audience segmentation and analytics. There's no need to add extra integrations with CDPs to segment visitors or with analytics tools to gather insights.

What's included instead:

  • server-side AB testing with real-time audience segmentation
  • server-side content personalization based on location, behavior, or custom rules
  • a visitor profile explorer to analyze the user journey using out-of-the-box events
  • built-in analytics with Bayesian analysis for every variant and experience

Server-side matters for the same reason it did in the Jamstack framing earlier. The personalized variant is resolved before the page reaches the browser, so there's no flash of default content being swapped out.

Everything for conversion optimization

From personalization and experimentation to content and data management, we have all you need to deliver better user experiences.

Side-by-side comparison

Layered in-house buildCroct
GranularityField- or page-levelModule or component-level
Schema impactVariant fields and experiment IDs added throughoutNone
Variant assignmentYou build itHandled by the SDK
Query wiringParams per fieldOne fetchContent call with a fallback
Exposure trackingYou build itBuilt-in analytics
Segmentation sourceExternal CDP plus a segment-to-variant lookupBuilt-in audiences and profiles
StatisticsYou build itBuilt-in Bayesian analysis
Adding a segmentCDP change, lookup update, query params, deployDefined in the UI
RollbackUnpick several layersRemove one fetchContent call
Ongoing ownerYour engineering teamCroct

My recommendation

I recognize that, as Croct's founder, I may be biased. But the difference is pretty obvious, right?

Sanity's flexibility argument is right about content modeling and incomplete about delivery. Modeling variants as structured fields really does outlast a rigid preconfigured solution. But the six layers between a modeled variant and a measured result aren't content modeling. They're infrastructure, and building them in-house means your team owns a personalization platform.

Attaching a dynamic layer per module keeps Sanity where it's genuinely best and stops treating identity resolution, variant assignment, exposure tracking, and Bayesian statistics as things your team should be maintaining. Any content already managed in Sanity becomes personalizable and ready for AB testing, without changing how you structure or deliver it.

A reasonable first move: pick the single module with the most traffic and the clearest conversion role, usually the homepage hero, and connect it to a slot. Then launch one experience, targeting returning visitors, and watch it for a couple of weeks before committing to create a new platform.

If you want to explore it deeper, our Sanity integration guide and demo project are helpful resources. For a wider comparison of the plugin, GrowthBook, LaunchDarkly, and Croct, see how to use Sanity CMS for AB testing and website personalization.

Let's grow together!

Learn practical tactics our customers use to grow by 20% or more.

By continuing, you agree to our Terms & Privacy Policy.