---
title: "How to personalize Sanity content based on visitor behavior"
slug: "personalization-sanity-visitor-behavior"
locale: "en-us"
description: "Sanity models content variants well and leaves delivery to you. Compare building the six-layer stack yourself with attaching a dynamic layer per module, while keeping your schemas untouched."
published_at: "2026-07-29"
updated_at: "2026-07-29"
image: "https://storage.googleapis.com/croct-assets-b931d070/blog/How_to_personalize_Sanity_content_based_on_visitor_behavior_fa92395334/How_to_personalize_Sanity_content_based_on_visitor_behavior_fa92395334.png"
authors: ["Juliana Amorim"]
category: "Personalization"
tags: ["Segmentation","Comparison","How To","Conversion Rate Optimization (CRO)"]
canonical: "https://blog.croct.com/post/personalization-sanity-visitor-behavior"
---

# 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](https://www.sanity.io/guides/personalization-localization) 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 the two most used ways teams close that gap. You can either develop the delivery stack yourself, layer by layer, or attach a dynamic layer to each content module you want to optimize and let Sanity keep doing what it's good at.

## 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](https://www.sanity.io/guides/personalization-localization) 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](https://gitnation.com/contents/how-do-localise-and-personalize-content-with-sanityio-and-nextjs) 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](https://www.sanity.io/plugins/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.

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 explore this on option 1.

## 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](https://www.youtube.com/watch?v=DNEHV5Qz5i0), 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. In the GrowthBook guide's example, there are six parameters: `titleExperiment`, `titleVariant`, `descriptionExperiment`, `descriptionVariant`, `imageExperiment`, and `imageVariant`.

Three fields and six parameters for a single product page testing. 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.

### 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.

```shellcmd noLineNumber
npx croct@latest init
```

Sanity's own official Next.js template has a [version of this integration](https://github.com/croct-tech/croct-sanity-project) committed to it, and the README describes the scope precisely: adding Croct to an existing Sanity + Next.js project takes [three small changes](https://github.com/croct-tech/croct-sanity-project#how-to-get-started), 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 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.

## Side-by-side comparison

|                         | Layered in-house build                             | Croct                                   |
| ----------------------- | -------------------------------------------------- | --------------------------------------- |
| **Granularity**         | Field- or page-level)                              | Module or component-level               |
| **Schema impact**       | Variant fields and experiment IDs added throughout | None                                    |
| **Variant assignment**  | You build it                                       | Handled by the SDK                      |
| **Query wiring**        | Params per field                                   | One `fetchContent` call with a fallback |
| **Exposure tracking**   | You build it                                       | Built-in analytics                      |
| **Segmentation source** | External CDP plus a segment-to-variant lookup      | Built-in audiences and profiles         |
| **Statistics**          | You build it                                       | Built-in Bayesian analysis              |
| **Adding a segment**    | CDP change, lookup update, query params, deploy    | Defined in the UI                       |
| **Rollback**            | Unpick several layers                              | Remove one `fetchContent` call          |
| **Ongoing owner**       | Your engineering team                              | The vendor                              |

## 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](https://docs.croct.com/immersion/integrations/cms/sanity) and [demo project](https://github.com/croct-tech/croct-sanity-project) are helpful resources.
