@sanity/personalization-plugin 2.5.0-launch-darkly.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +640 -109
  3. package/dist/growthbook/index.d.ts +12 -15
  4. package/dist/growthbook/index.d.ts.map +1 -0
  5. package/dist/growthbook/index.js +104 -68
  6. package/dist/growthbook/index.js.map +1 -1
  7. package/dist/index.d.ts +212 -252
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +417 -7195
  10. package/dist/index.js.map +1 -1
  11. package/dist/launchDarkly/index.d.ts +9 -12
  12. package/dist/launchDarkly/index.d.ts.map +1 -0
  13. package/dist/launchDarkly/index.js +74 -46
  14. package/dist/launchDarkly/index.js.map +1 -1
  15. package/package.json +36 -78
  16. package/dist/growthbook/index.d.mts +0 -15
  17. package/dist/growthbook/index.mjs +0 -124
  18. package/dist/growthbook/index.mjs.map +0 -1
  19. package/dist/index.d.mts +0 -267
  20. package/dist/index.mjs +0 -7347
  21. package/dist/index.mjs.map +0 -1
  22. package/dist/launchDarkly/index.d.mts +0 -12
  23. package/dist/launchDarkly/index.mjs +0 -107
  24. package/dist/launchDarkly/index.mjs.map +0 -1
  25. package/sanity.json +0 -8
  26. package/src/components/Array.tsx +0 -68
  27. package/src/components/ExperimentContext.tsx +0 -65
  28. package/src/components/ExperimentField.tsx +0 -138
  29. package/src/components/ExperimentInput.tsx +0 -75
  30. package/src/components/Select.tsx +0 -43
  31. package/src/components/VariantInput.tsx +0 -18
  32. package/src/components/VariantPreview.tsx +0 -75
  33. package/src/fieldExperiments.tsx +0 -264
  34. package/src/growthbook/Components/GrowthbookContext.tsx +0 -38
  35. package/src/growthbook/Components/Secrets.tsx +0 -47
  36. package/src/growthbook/index.ts +0 -54
  37. package/src/growthbook/types.ts +0 -15
  38. package/src/growthbook/utils.ts +0 -94
  39. package/src/index.ts +0 -3
  40. package/src/launchDarkly/components/LaunchDarklyContext.tsx +0 -36
  41. package/src/launchDarkly/components/Secrets.tsx +0 -46
  42. package/src/launchDarkly/index.ts +0 -52
  43. package/src/launchDarkly/launchdarkly.md +0 -76
  44. package/src/launchDarkly/types.ts +0 -193
  45. package/src/launchDarkly/utils.ts +0 -54
  46. package/src/types.ts +0 -245
  47. package/src/utils/flattenSchemaType.ts +0 -47
  48. package/v2-incompatible.js +0 -11
package/README.md CHANGED
@@ -1,30 +1,59 @@
1
1
  # @sanity/personalization-plugin
2
2
 
3
- ## Previously know as @sanity/personalisation-plugin
3
+ ## Previously known as @sanity/personalisation-plugin
4
4
 
5
- This plugin allows users to add a/b/n testing experiments to individual fields.
5
+ This plugin allows users to add a/b/n testing experiments to individual fields and page-level experiments.
6
+
7
+ > **Full Demo**
8
+ >
9
+ > 🚀 For a full working example of this plugin implemented with Next.js, see the [personalization-plugin-example](https://github.com/demo-repositories/personalization-plugin-example) repository.
10
+ >
11
+ > 🎬 Watch the [video walkthrough](https://www.loom.com/share/3e1314575b23434eb0aa35ccad9b9592) to see how the plugin works in a Next.js project.
6
12
 
7
13
  ![image](./overview.gif)
8
14
 
9
- For this plugin you need to defined the experiments you are running and the variations those experiments have. Each experiment needs to have an id, a label, and an array of variants that have an id and a label. You can either pass an array of experiments in the plugin config, or you can use and async function to retrieve the experiments and variants from an external service like growthbook, Amplitude, LaunchDarkly... You could even store the experiments in your sanity dataset.
15
+ For this plugin you need to define the experiments you are running and the variations those experiments have. Each experiment needs to have an id, a label, and an array of variants that have an id and a label. You can either pass an array of experiments in the plugin config, or you can use and async function to retrieve the experiments and variants from an external service like growthbook, Amplitude, LaunchDarkly... You could even store the experiments in your sanity dataset.
10
16
 
11
17
  Once configured you can query the values using the ids of the experiment and variant
12
18
 
13
- - [@sanity/personalization-plugin](#@sanity/personalization-plugin)
19
+ - [@sanity/personalization-plugin](#sanitypersonalization-plugin)
20
+ - [Previously known as @sanity/personalisation-plugin](#previously-known-as-sanitypersonalisation-plugin)
14
21
  - [Installation](#installation)
22
+ - [When to Use This Plugin](#when-to-use-this-plugin)
15
23
  - [Usage](#usage)
16
24
  - [Loading Experiments](#loading-experiments)
25
+ - [Option 1: Static Array](#option-1-static-array)
26
+ - [Option 2: Fetch from External Service](#option-2-fetch-from-external-service)
27
+ - [Option 3: Store in Sanity Dataset](#option-3-store-in-sanity-dataset)
17
28
  - [Using complex field configurations](#using-complex-field-configurations)
29
+ - [Page-Level Experiments](#page-level-experiments)
30
+ - [Step 1: Configure the Plugin with a Reference Field](#step-1-configure-the-plugin-with-a-reference-field)
31
+ - [Step 2: Create a Route Experiment Document Type](#step-2-create-a-route-experiment-document-type)
32
+ - [Step 3: Query the Correct Page](#step-3-query-the-correct-page)
33
+ - [Step 4: Implement Proxy for Routing](#step-4-implement-proxy-for-routing)
18
34
  - [Validation of individual array items](#validation-of-individual-array-items)
19
35
  - [Shape of stored data](#shape-of-stored-data)
20
36
  - [Querying data](#querying-data)
37
+ - [Variant Assignment](#variant-assignment)
38
+ - [Variant ID Consistency](#variant-id-consistency)
39
+ - [Cookie-Based Assignment](#cookie-based-assignment)
40
+ - [Reading Variants in Page Components](#reading-variants-in-page-components)
41
+ - [Third-Party Integration](#third-party-integration)
42
+ - [Split testing (URL-based)](#split-testing-url-based)
43
+ - [Studio Setup](#studio-setup)
44
+ - [Frontend usage](#frontend-usage)
21
45
  - [Using experiment fields in an array](#using-experiment-fields-in-an-array)
46
+ - [Overwriting the experiment and variant field names](#overwriting-the-experiment-and-variant-field-names)
47
+ - [Example: Audience Segmentation](#example-audience-segmentation)
48
+ - [Stored Data Structure](#stored-data-structure)
49
+ - [Querying with Custom Field Names](#querying-with-custom-field-names)
22
50
  - [License](#license)
23
51
  - [Develop \& test](#develop--test)
24
52
  - [Release new version](#release-new-version)
25
- - [License](#license-1)
26
53
 
27
- For Specific information about the growthbookFieldLevel export see its [readme](/growthbook.md)
54
+ > For Specific information about the Growthbook FieldLevel export see its [readme](/growthbook.md)
55
+ >
56
+ > For Specific information about the LaunchDarkly FieldLevel export see its [readme](/launchdarkly.md)
28
57
 
29
58
  ## Installation
30
59
 
@@ -32,6 +61,27 @@ For Specific information about the growthbookFieldLevel export see its [readme](
32
61
  npm install @sanity/personalization-plugin
33
62
  ```
34
63
 
64
+ ## When to Use This Plugin
65
+
66
+ This plugin supports two types of A/B testing:
67
+
68
+ | Type | Use Case | Example |
69
+ | --------------- | ---------------------------------------------- | ------------------------------------------- |
70
+ | **Field-Level** | Test different content values on the same page | Different headlines, CTAs, or descriptions |
71
+ | **Page-Level** | Test entirely different page layouts | Different homepage designs or landing pages |
72
+
73
+ **Choose Field-Level when:**
74
+
75
+ - You want to test a single element (headline, button text, image)
76
+ - The page structure stays the same
77
+ - You need fine-grained control over individual content pieces
78
+
79
+ **Choose Page-Level when:**
80
+
81
+ - You want to test completely different page designs
82
+ - Multiple elements change together as part of a cohesive variant
83
+ - You're running landing page optimization tests
84
+
35
85
  ## Usage
36
86
 
37
87
  Add it as a plugin in `sanity.config.ts` (or .js):
@@ -40,31 +90,38 @@ Add it as a plugin in `sanity.config.ts` (or .js):
40
90
  import {defineConfig} from 'sanity'
41
91
  import {fieldLevelExperiments} from '@sanity/personalization-plugin'
42
92
 
43
- const experiment1 = {
44
- id: '123',
45
- label: 'experiment 1',
93
+ // Example: Testing different homepage headlines
94
+ const headlineExperiment = {
95
+ id: 'homepage-headline',
96
+ label: 'Homepage Headline Test',
46
97
  variants: [
47
98
  {
48
- id: '123-a',
49
- label: 'first var',
99
+ id: 'control',
100
+ label: 'Control',
50
101
  },
51
102
  {
52
- id: '123-b',
53
- label: 'second var',
103
+ id: 'emotional',
104
+ label: 'Emotional Appeal',
54
105
  },
55
106
  ],
56
107
  }
57
- const experiment2 = {
58
- id: '456',
59
- label: 'experiment 2',
108
+
109
+ // Example: Testing different signup button text
110
+ const ctaExperiment = {
111
+ id: 'signup-cta',
112
+ label: 'Signup CTA Test',
60
113
  variants: [
61
114
  {
62
- id: '456-a',
63
- label: 'b first var',
115
+ id: 'control',
116
+ label: 'Control',
117
+ },
118
+ {
119
+ id: 'urgent',
120
+ label: 'Urgency Messaging',
64
121
  },
65
122
  {
66
- id: '456-b',
67
- label: 'b second var',
123
+ id: 'benefit',
124
+ label: 'Benefit Focused',
68
125
  },
69
126
  ],
70
127
  }
@@ -75,22 +132,22 @@ export default defineConfig({
75
132
  //...
76
133
  fieldLevelExperiments({
77
134
  fields: ['string'],
78
-
79
- experiments: [experiment1, experiment2],
135
+ experiments: [headlineExperiment, ctaExperiment],
80
136
  }),
81
137
  ],
82
138
  })
83
139
  ```
84
140
 
85
- This will register two new fields to the schema., based on the setting passed intto `fields:`
141
+ This will register two new fields to the schema based on the setting passed into `fields:`:
86
142
 
87
- - `experimentString` an Object field with `string` field called `default`, a `string` field called `experimentId` and an array field called `variants` of type:
88
- - `variantString` an object field with a `string` field called `value`, a string field called `variantId`, a `string` field called `experimentId`.
143
+ - `experimentString` - An object field with a `string` field called `default`, a `string` field called `experimentId`, and an array field called `variants`
144
+ - `variantString` - An object field with a `string` field called `value`, a string field called `variantId`, and a `string` field called `experimentId`
89
145
 
90
146
  Use the experiment field in your schema like this:
91
147
 
92
148
  ```ts
93
- //for Example in post.ts
149
+ // Example: blog post with A/B testable title
150
+ // In post.ts
94
151
 
95
152
  fields: [
96
153
  defineField({
@@ -100,84 +157,94 @@ fields: [
100
157
  ]
101
158
  ```
102
159
 
160
+ When editors open a document with this field, they can:
161
+
162
+ 1. Enter a **default value** (shown to users not in an experiment)
163
+ 2. Click the <img src="./beaker.svg" alt="beaker icon" width="28"> **beaker icon** ("Add experiment") to assign an experiment
164
+ 3. Enter **variant-specific values** for each variant in the experiment
165
+
166
+ ![Field-level experiment — click the beaker icon to add an experiment](./field-experiment.png)
167
+
168
+ > 💡 **Tip:** Look for the <img src="./beaker.svg" alt="beaker icon" width="28"> beaker icon in the field toolbar — clicking it opens the experiment picker where you can assign an experiment and enter variant-specific values.
169
+
103
170
  ## Loading Experiments
104
171
 
105
- Experiments must be an array of objects with an id and label and an array of variants objects with an id and label.
172
+ Experiments must be an array of objects with an `id`, `label`, and an array of `variants` (each with `id` and `label`).
173
+
174
+ **Important:** The variant `id` values must match what your frontend uses to assign users to variants (typically via cookies).
175
+
176
+ ### Option 1: Static Array
177
+
178
+ Define experiments directly in your config:
106
179
 
107
180
  ```ts
108
181
  experiments: [
109
182
  {
110
- id: '123',
111
- label: 'experiment 1',
183
+ id: 'homepage-headline',
184
+ label: 'Homepage Headline Test',
112
185
  variants: [
113
- {
114
- id: '123-a',
115
- label: 'first var',
116
- },
117
- {
118
- id: '123-b',
119
- label: 'second var',
120
- },
186
+ {id: 'control', label: 'Control'},
187
+ {id: 'emotional', label: 'Emotional Appeal'},
121
188
  ],
122
189
  },
123
190
  {
124
- id: '456',
125
- label: 'experiment 2',
191
+ id: 'signup-cta',
192
+ label: 'Signup CTA Test',
126
193
  variants: [
127
- {
128
- id: '456-a',
129
- label: 'b first var',
130
- },
131
- {
132
- id: '456-b',
133
- label: 'b second var',
134
- },
194
+ {id: 'control', label: 'Control'},
195
+ {id: 'urgent', label: 'Urgency Messaging'},
196
+ {id: 'benefit', label: 'Benefit Focused'},
135
197
  ],
136
198
  },
137
199
  ]
138
200
  ```
139
201
 
140
- Or an asynchronous function that returns an array of objects with an id and label and an array of variants objects with an id and label.
202
+ ### Option 2: Fetch from External Service
203
+
204
+ Use an async function to load experiments from services like GrowthBook, Amplitude, or LaunchDarkly:
141
205
 
142
206
  ```ts
143
207
  experiments: async () => {
144
- const response = await fetch('https://example.com/experiments')
145
- const {externalExperiments} = await response.json()
146
-
147
- const experiments: ExperimentType[] = externalExperiments?.map((experiment) => {
148
- const experimentId = experiment.id
149
- const experimentLabel = experiment.name
150
- const variants = experiment.variations?.map((variant) => {
151
- return {
152
- id: variant.variationId,
153
- label: variant.name,
154
- }
155
- })
156
- return {
157
- id: experimentId,
158
- label: experimentLabel,
159
- variants,
160
- }
161
- })
162
- return experiments
208
+ const response = await fetch('https://api.growthbook.io/experiments')
209
+ const {experiments: externalExperiments} = await response.json()
210
+
211
+ return externalExperiments?.map((experiment) => ({
212
+ id: experiment.id,
213
+ label: experiment.name,
214
+ variants: experiment.variations?.map((variant) => ({
215
+ id: variant.variationId,
216
+ label: variant.name,
217
+ })),
218
+ }))
163
219
  }
164
220
  ```
165
221
 
166
- The async function contains a configured Sanity Client in the first parameter, allowing you to store Language options as documents. Your query should return an array of objects with an id and title.
222
+ ### Option 3: Store in Sanity Dataset
223
+
224
+ The async function receives a configured Sanity Client, allowing you to store experiments as documents:
167
225
 
168
226
  ```ts
169
227
  experiments: async (client) => {
170
- const experiments = await client.fetch(`*[_type == 'experiment']`)
171
- return experiments
172
- },
228
+ // Fetch experiment documents from your dataset
229
+ const experiments = await client.fetch(`
230
+ *[_type == 'experiment']{
231
+ id,
232
+ label,
233
+ variants[]{id, label}
234
+ }
235
+ `)
236
+ return experiments
237
+ }
173
238
  ```
174
239
 
240
+ This approach lets content editors create and manage experiments directly in Sanity Studio without code changes.
241
+
175
242
  ## Using complex field configurations
176
243
 
177
244
  For more control over the value field, you can pass a schema definition into the fields array.
178
245
 
179
246
  ```ts
180
- import {defineConfig} from 'sanity'
247
+ import {defineConfig, defineField} from 'sanity'
181
248
  import {fieldLevelExperiments} from '@sanity/personalization-plugin'
182
249
 
183
250
  export default defineConfig({
@@ -193,19 +260,171 @@ export default defineConfig({
193
260
  hidden: ({document}) => !document?.title,
194
261
  }),
195
262
  ],
196
- experiments: [experiment1, experiment2],
263
+ experiments: [headlineExperiment, ctaExperiment],
197
264
  }),
198
265
  ],
199
266
  })
200
267
  ```
201
268
 
202
- This would also create two new fields in your schema.
269
+ This would also create two new fields in your schema:
270
+
271
+ - `experimentFeaturedProduct` - An object field with a `reference` field called `default`, a `string` field called `experimentId`, and an array field called `variants`
272
+ - `variantFeaturedProduct` - An object field with a `reference` field called `value`, a string field called `variantId`, and a `string` field called `experimentId`
203
273
 
204
- - `experimentFeaturedProduct` an Object field with `reference` field called `default`, a `string` field called `experimentId` and an array field called `variants` of type:
205
- - `variantFeaturedProduct` an object field with a `reference` field called `value`, a string field called `variandId`, a `string` field called `experimentId`.
274
+ Note that the `name` key in the field definition is used to name the generated field type, while the actual field inside is always called `value`.
206
275
 
207
- Note that the name key in the field gets rewritten to value and is instead used to name the object field.
276
+ ## Page-Level Experiments
208
277
 
278
+ You can use this plugin to A/B test entire pages by experimenting on reference fields. This is useful when you want to show completely different page content to different user segments.
279
+
280
+ ![Page-level experiment — click the beaker icon to add an experiment](./page-experiment.png)
281
+
282
+ > 💡 **Tip:** Just like field-level experiments, click the <img src="./beaker.svg" alt="beaker icon" width="28"> beaker icon on the reference field to assign an experiment and configure variant-specific pages.
283
+
284
+ ### Step 1: Configure the Plugin with a Reference Field
285
+
286
+ ```ts
287
+ import {defineConfig, defineField} from 'sanity'
288
+ import {fieldLevelExperiments} from '@sanity/personalization-plugin'
289
+
290
+ const homepageExperiment = {
291
+ id: 'homepage-redesign',
292
+ label: 'Homepage Redesign Test',
293
+ variants: [
294
+ {id: 'control', label: 'Control (Current Design)'},
295
+ {id: 'variant-a', label: 'Variant A (New Design)'},
296
+ ],
297
+ }
298
+
299
+ export default defineConfig({
300
+ //...
301
+ plugins: [
302
+ fieldLevelExperiments({
303
+ fields: [
304
+ 'string',
305
+ // Add a reference field for page-level experiments
306
+ defineField({
307
+ name: 'page',
308
+ type: 'reference',
309
+ to: [{type: 'page'}, {type: 'homePage'}],
310
+ }),
311
+ ],
312
+ experiments: [homepageExperiment],
313
+ }),
314
+ ],
315
+ })
316
+ ```
317
+
318
+ ### Step 2: Create a Route Experiment Document Type
319
+
320
+ Create a document type to store which pages should be shown for each route:
321
+
322
+ ```ts
323
+ import {defineType, defineField} from 'sanity'
324
+
325
+ export const routeExperiment = defineType({
326
+ name: 'routeExperiment',
327
+ title: 'Route Experiment',
328
+ type: 'document',
329
+ fields: [
330
+ defineField({
331
+ name: 'name',
332
+ title: 'Experiment Name',
333
+ type: 'string',
334
+ validation: (Rule) => Rule.required(),
335
+ }),
336
+ defineField({
337
+ name: 'targetRoute',
338
+ title: 'Target Route',
339
+ type: 'string',
340
+ description: 'The URL path this experiment applies to (e.g., "/" for homepage)',
341
+ validation: (Rule) => Rule.required(),
342
+ }),
343
+ defineField({
344
+ name: 'isActive',
345
+ title: 'Active',
346
+ type: 'boolean',
347
+ initialValue: false,
348
+ }),
349
+ defineField({
350
+ name: 'page',
351
+ title: 'Page',
352
+ type: 'experimentPage', // Auto-generated by the plugin
353
+ description: 'Select default page and variant pages',
354
+ }),
355
+ ],
356
+ })
357
+ ```
358
+
359
+ ### Step 3: Query the Correct Page
360
+
361
+ Use GROQ to resolve the correct page based on experiment and variant:
362
+
363
+ ```ts
364
+ const ROUTE_EXPERIMENT_QUERY = `
365
+ *[_type == "routeExperiment" && targetRoute == $path && isActive == true][0]{
366
+ "page": coalesce(
367
+ page.variants[experimentId == $experimentId && variantId == $variantId][0].value,
368
+ page.default
369
+ )->{
370
+ _id,
371
+ _type,
372
+ title,
373
+ slug,
374
+ // ... other page fields
375
+ }
376
+ }
377
+ `
378
+ ```
379
+
380
+ > The `slug` field is required for the slug-based path rewrite (Option B in Step 4).
381
+
382
+ ### Step 4: Implement Proxy for Routing
383
+
384
+ In your frontend (e.g., Next.js proxy), determine which page to serve. Two approaches are supported:
385
+
386
+ **Option A: Same URL (pageId query param)** — Keeps the visible URL stable. Best for A/B tests where users always see the same path.
387
+
388
+ ```ts
389
+ // proxy.ts
390
+ import {NextResponse} from 'next/server'
391
+ import type {NextRequest} from 'next/server'
392
+
393
+ export async function proxy(request: NextRequest) {
394
+ const pathname = request.nextUrl.pathname
395
+
396
+ // Get user's assigned variant from cookie
397
+ const variantId = request.cookies.get('ab-variant')?.value || 'control'
398
+
399
+ // Fetch the experiment configuration
400
+ const data = await client.fetch(ROUTE_EXPERIMENT_QUERY, {
401
+ path: pathname,
402
+ experimentId: 'homepage-redesign',
403
+ variantId: variantId,
404
+ })
405
+
406
+ if (data?.page) {
407
+ // Rewrite to the selected page (same URL, pass pageId)
408
+ const url = request.nextUrl.clone()
409
+ url.searchParams.set('pageId', data.page._id)
410
+ return NextResponse.rewrite(url)
411
+ }
412
+
413
+ return NextResponse.next()
414
+ }
415
+ ```
416
+
417
+ **Option B: Slug-based path rewrite** — Rewrites the URL to the variant page's slug. Use when your app routes by slug (e.g. `app/[[...slug]]/page.tsx`) and you want the URL to reflect the variant.
418
+
419
+ ```ts
420
+ if (data?.page?.slug?.current) {
421
+ // Rewrite to the variant's slug path
422
+ const url = request.nextUrl.clone()
423
+ url.pathname = `/${data.page.slug.current}`
424
+ return NextResponse.rewrite(url)
425
+ }
426
+ // If slug is missing, the request continues without rewriting
427
+ ```
209
428
 
210
429
  ## Validation of individual array items
211
430
 
@@ -237,39 +456,303 @@ defineField({
237
456
 
238
457
  ## Shape of stored data
239
458
 
240
- The custom input contains buttons which will add new array items with the experiment and variant already populated. Data returned from this array will look like this:
459
+ The custom input contains buttons which will add new array items with the experiment and variant already populated. Data returned from this field will look like this:
241
460
 
242
461
  ```json
243
462
  "title": {
244
- "default": "asdf",
245
- "experimentId": "test-1",
463
+ "default": "Welcome to Our Platform",
464
+ "experimentId": "homepage-headline",
246
465
  "variants": [
247
466
  {
248
- "experimentId": "test-1",
249
- "value": "asdf",
250
- "variantId": "test-1-a"
467
+ "experimentId": "homepage-headline",
468
+ "variantId": "control",
469
+ "value": "Welcome to Our Platform"
251
470
  },
252
471
  {
253
- "experimentId": "test-1",
254
- "variantId": "test-1-b",
255
- "value": "asdf"
472
+ "experimentId": "homepage-headline",
473
+ "variantId": "emotional",
474
+ "value": "Transform Your Life Today"
256
475
  }
257
476
  ]
258
477
  }
259
478
  ```
260
479
 
261
- Querying data
262
- Using GROQ filters you can query for a specific experiment, with a fallback to default value like so:
480
+ In this example:
481
+
482
+ - `default` is shown to users not in an experiment
483
+ - `control` variant shows "Welcome to Our Platform"
484
+ - `emotional` variant shows "Transform Your Life Today"
485
+
486
+ ## Querying data
487
+
488
+ Use GROQ's `coalesce` function to query for a specific variant with a fallback to the default value:
263
489
 
264
490
  ```ts
491
+ // Fetch blog posts with experiment-aware title
265
492
  *[_type == "post"] {
266
- "title":coalesce(title.variants[experimentId == $experiment && variantId == $variant][0].value, title.default),
493
+ "title": coalesce(
494
+ title.variants[experimentId == $experimentId && variantId == $variantId][0].value,
495
+ title.default
496
+ ),
497
+ // ... other fields
267
498
  }
268
499
  ```
269
500
 
501
+ On your frontend, pass the experiment and variant IDs as query parameters:
502
+
503
+ ```ts
504
+ const posts = await client.fetch(query, {
505
+ experimentId: 'homepage-headline',
506
+ variantId: userVariant, // e.g., 'control' or 'emotional'
507
+ })
508
+ ```
509
+
510
+ This pattern ensures:
511
+
512
+ 1. Users in the experiment see their assigned variant's content
513
+ 2. Users not in an experiment see the default value
514
+ 3. The query works even if no variants are defined (falls back to default)
515
+
516
+ ## Variant Assignment
517
+
518
+ For experiments to work, your frontend must assign users to variants and pass the correct variant ID when querying content.
519
+
520
+ ### Variant ID Consistency
521
+
522
+ **Important:** The variant IDs in your plugin configuration must match exactly what your frontend uses.
523
+
524
+ ```ts
525
+ // Studio config - these IDs must match your frontend
526
+ const experiment = {
527
+ id: 'homepage-headline',
528
+ variants: [
529
+ {id: 'control', label: 'Control'}, // ID: 'control'
530
+ {id: 'variant-a', label: 'Variant A'}, // ID: 'variant-a'
531
+ ],
532
+ }
533
+ ```
534
+
535
+ ### Cookie-Based Assignment
536
+
537
+ The most common approach is to assign variants via cookies on first visit. Using MurmurHash with a userId gives better distribution and deterministic assignment (the same user always gets the same variant):
538
+
539
+ ```ts
540
+ // In Next.js proxy (proxy.ts)
541
+ import {NextResponse} from 'next/server'
542
+ import type {NextRequest} from 'next/server'
543
+ import {v4} from 'uuid'
544
+ import MurmurHash3 from 'imurmurhash'
545
+
546
+ const COOKIE_MAX_AGE = 60 * 60 * 24 * 30 // 30 days
547
+
548
+ export function proxy(request: NextRequest) {
549
+ const response = NextResponse.next()
550
+
551
+ // Check if user already has a variant
552
+ let variant = request.cookies.get('ab-variant')?.value
553
+
554
+ if (!variant) {
555
+ // Use logged-in user ID if available, else persisted or new anonymous ID
556
+ const userId =
557
+ getUserIdFromSession(request) ?? // Implement: return session?.user?.id etc.
558
+ request.cookies.get('ab-user-id')?.value ??
559
+ v4()
560
+
561
+ // Deterministic variant from hash (same userId → same variant)
562
+ variant = MurmurHash3(userId).result() % 2 ? 'control' : 'variant-a'
563
+
564
+ response.cookies.set('ab-variant', variant, {maxAge: COOKIE_MAX_AGE, path: '/'})
565
+ // Persist anonymous ID when we created a new one (stable until user logs in)
566
+ if (!getUserIdFromSession(request) && !request.cookies.get('ab-user-id')?.value) {
567
+ response.cookies.set('ab-user-id', userId, {maxAge: COOKIE_MAX_AGE, path: '/'})
568
+ }
569
+ }
570
+
571
+ return response
572
+ }
573
+ ```
574
+
575
+ > **Tip:** Install with `npm install uuid imurmurhash`. When a user logs in, update the `ab-user-id` cookie to their real user ID so variant assignment stays consistent across sessions.
576
+ >
577
+ > > **Auth integration:** Implement `getUserIdFromSession(request)` to return the logged-in user's ID (e.g. `getServerSession()?.user?.id` with NextAuth). If your app has no auth, leave it as a stub that returns `undefined` so anonymous users get a UUID-based assignment.
578
+
579
+ ### Reading Variants in Page Components
580
+
581
+ In your page components, read the variant from cookies:
582
+
583
+ ```ts
584
+ import {cookies} from 'next/headers'
585
+
586
+ async function getVariant(): Promise<string> {
587
+ const cookieStore = await cookies()
588
+ const abCookie = cookieStore.get('ab-variant')?.value
589
+ return abCookie || 'control'
590
+ }
591
+
592
+ export default async function Page() {
593
+ const variant = await getVariant()
594
+
595
+ const data = await client.fetch(query, {
596
+ experimentId: 'homepage-headline',
597
+ variantId: variant,
598
+ })
599
+
600
+ // Render with experiment-aware content
601
+ }
602
+ ```
603
+
604
+ ### Third-Party Integration
605
+
606
+ For advanced use cases, you can integrate with experimentation platforms like GrowthBook, LaunchDarkly, or Amplitude. These platforms handle variant assignment and provide analytics. See the [GrowthBook](/growthbook.md) and [LaunchDarkly](/launchdarkly.md) integration guides for details.
607
+
608
+ ## Split testing (URL-based)
609
+
610
+ Split testing involves routing users at one URL to different pages. Use this when you want to test completely different page layouts, not just individual fields.
611
+
612
+ ### Studio Setup
613
+
614
+ First, define a custom path type for URL validation:
615
+
616
+ ```ts
617
+ import {defineType} from 'sanity'
618
+
619
+ export const path = defineType({
620
+ name: 'path',
621
+ type: 'string',
622
+ validation: (Rule) =>
623
+ Rule.required().custom(async (value: string | undefined) => {
624
+ if (!value) return true
625
+ if (!value.startsWith('/')) return 'Must start with "/"'
626
+ return true
627
+ }),
628
+ })
629
+ ```
630
+
631
+ Add the path type to your schema and plugin config:
632
+
633
+ ```ts
634
+ fieldLevelExperiments({
635
+ fields: ['path', 'string'], // Include 'path' for URL experiments
636
+ experiments: [
637
+ {
638
+ id: 'landing-page-test',
639
+ label: 'Landing Page A/B Test',
640
+ variants: [
641
+ { id: 'control', label: 'Control' },
642
+ { id: 'variant-a', label: 'Variant A' },
643
+ ],
644
+ },
645
+ ],
646
+ }),
647
+ ```
648
+
649
+ Create a document type to store routing experiments:
650
+
651
+ ```ts
652
+ import {defineType, defineField} from 'sanity'
653
+
654
+ export const routing = defineType({
655
+ name: 'routing',
656
+ type: 'document',
657
+ title: 'URL Split Tests',
658
+ fields: [
659
+ defineField({
660
+ name: 'pathExperiment',
661
+ title: 'URL Path Experiment',
662
+ type: 'experimentPath',
663
+ initialValue: {active: true},
664
+ description: 'Set the default URL and variant URLs for this test',
665
+ }),
666
+ ],
667
+ preview: {
668
+ select: {
669
+ path: 'pathExperiment.default',
670
+ experiment: 'pathExperiment.experimentId',
671
+ },
672
+ prepare({path, experiment}) {
673
+ return {
674
+ title: `${path}`,
675
+ subtitle: `Experiment: ${experiment || 'None'}`,
676
+ }
677
+ },
678
+ },
679
+ })
680
+ ```
681
+
682
+ ### Frontend usage
683
+
684
+ Use a proxy to intercept requests and route users to the appropriate page based on their variant assignment:
685
+
686
+ ```ts
687
+ // proxy.ts
688
+ import {NextResponse} from 'next/server'
689
+ import type {NextRequest} from 'next/server'
690
+ import {v4} from 'uuid'
691
+ import MurmurHash3 from 'imurmurhash'
692
+ import {client} from './lib/sanity'
693
+
694
+ const ROUTING_QUERY = `*[
695
+ _type == "routing" &&
696
+ pathExperiment.default == $path
697
+ ][0]{
698
+ "route": coalesce(
699
+ pathExperiment.variants[experimentId == $experimentId && variantId == $variantId][0].value,
700
+ pathExperiment.default
701
+ )
702
+ }`
703
+
704
+ const COOKIE_MAX_AGE = 60 * 60 * 24 * 30 // 30 days
705
+
706
+ export async function proxy(request: NextRequest) {
707
+ const pathname = request.nextUrl.pathname
708
+
709
+ // Get user's variant from cookie (set on first visit)
710
+ let variantId = request.cookies.get('ab-variant')?.value
711
+
712
+ const response = NextResponse.next()
713
+
714
+ if (!variantId) {
715
+ const userId =
716
+ getUserIdFromSession(request) ?? // Implement: return session?.user?.id etc.
717
+ request.cookies.get('ab-user-id')?.value ??
718
+ v4()
719
+ variantId = MurmurHash3(userId).result() % 2 ? 'control' : 'variant-a'
720
+ if (!getUserIdFromSession(request) && !request.cookies.get('ab-user-id')?.value) {
721
+ response.cookies.set('ab-user-id', userId, {maxAge: COOKIE_MAX_AGE, path: '/'})
722
+ }
723
+ }
724
+ response.cookies.set('ab-variant', variantId, {maxAge: COOKIE_MAX_AGE, path: '/'})
725
+
726
+ // Query for URL routing experiments
727
+ const data = await client.fetch(ROUTING_QUERY, {
728
+ path: pathname,
729
+ experimentId: 'landing-page-test',
730
+ variantId: variantId,
731
+ })
732
+
733
+ if (data?.route && data.route !== pathname) {
734
+ const url = request.nextUrl.clone()
735
+ url.pathname = data.route
736
+ const rewrite = NextResponse.rewrite(url)
737
+ // Preserve the cookie on the rewrite response
738
+ rewrite.cookies.set('ab-variant', variantId, {maxAge: COOKIE_MAX_AGE, path: '/'})
739
+ return rewrite
740
+ }
741
+
742
+ return response
743
+ }
744
+
745
+ export const config = {
746
+ matcher: ['/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)'],
747
+ }
748
+ ```
749
+
750
+ **Tip:** For better performance, consider querying all routing experiments at build time and caching them, rather than fetching on every request.
751
+
270
752
  ## Using experiment fields in an array
271
753
 
272
754
  You may want to add experiment fields as a type with in an array in oder to do this you would need to set an initial value for the experiment to active the schema would be something like:
755
+
273
756
  ```ts
274
757
  defineField({
275
758
  name: 'components',
@@ -284,7 +767,7 @@ defineField({
284
767
  }),
285
768
  ```
286
769
 
287
- You can then use a groq filter to return the base version of you array member so you don't have to create an experiment specific version
770
+ You can then use a groq filter to return the base version of you array member so you don't have to create an experiment specific version
288
771
 
289
772
  ```ts
290
773
  *[
@@ -304,19 +787,46 @@ You can then use a groq filter to return the base version of you array member so
304
787
 
305
788
  ## Overwriting the experiment and variant field names
306
789
 
307
- If your use case does not match exactly with experiments you can overwrite the name field names for experiment and variant in the config.
790
+ If your use case doesn't match the "experiment/variant" terminology, you can rename these fields. This is useful for:
791
+
792
+ - **Audience-based personalization**: Show different content to different user segments (e.g., "enterprise customers" vs "small business")
793
+ - **Locale-based content**: Display region-specific messaging
794
+ - **Feature flags**: Toggle content based on feature availability
795
+
796
+ ### Example: Audience Segmentation
308
797
 
309
798
  ```ts
310
799
  import {defineConfig} from 'sanity'
311
800
  import {fieldLevelExperiments} from '@sanity/personalization-plugin'
312
801
 
802
+ // Define your audiences and segments
803
+ const audiences = [
804
+ {
805
+ id: 'customer-type',
806
+ label: 'Customer Type',
807
+ variants: [
808
+ {id: 'enterprise', label: 'Enterprise'},
809
+ {id: 'small-business', label: 'Small Business'},
810
+ {id: 'individual', label: 'Individual'},
811
+ ],
812
+ },
813
+ {
814
+ id: 'subscription-tier',
815
+ label: 'Subscription Tier',
816
+ variants: [
817
+ {id: 'free', label: 'Free Tier'},
818
+ {id: 'pro', label: 'Pro Tier'},
819
+ {id: 'enterprise', label: 'Enterprise Tier'},
820
+ ],
821
+ },
822
+ ]
823
+
313
824
  export default defineConfig({
314
825
  //...
315
826
  plugins: [
316
- //...
317
827
  fieldLevelExperiments({
318
828
  fields: ['string'],
319
- experiments: [experiment1, experiment2],
829
+ experiments: audiences,
320
830
  experimentNameOverride: 'audience',
321
831
  variantNameOverride: 'segment',
322
832
  }),
@@ -324,40 +834,61 @@ export default defineConfig({
324
834
  })
325
835
  ```
326
836
 
327
- This would also create two new fields in your schema.
837
+ This creates two new fields in your schema:
328
838
 
329
- - `audienceString` an Object field with `string` field called `default`, a `string` field called `audienceId` and an array field called `segments` of type:
330
- - `segmentString` an object field with a `string` field called `value`, a string field called `segmentId`, a `string` field called `audienceId`.
839
+ - `audienceString` - An object field with a `string` field called `default`, a `string` field called `audienceId`, and an array field called `segments`
840
+ - `segmentString` - An object field with a `string` field called `value`, a string field called `segmentId`, and a `string` field called `audienceId`
331
841
 
332
- the data will be stored as
842
+ ### Stored Data Structure
843
+
844
+ The data will be stored with your custom field names:
333
845
 
334
846
  ```json
335
- "title": {
336
- "default": "asdf",
337
- "audienceId": "test-1",
847
+ "headline": {
848
+ "default": "Welcome to Our Platform",
849
+ "audienceId": "customer-type",
338
850
  "segments": [
339
851
  {
340
- "audienceId": "test-1",
341
- "value": "asdf",
342
- "segmentId": "test-1-a"
852
+ "audienceId": "customer-type",
853
+ "segmentId": "enterprise",
854
+ "value": "Enterprise-Grade Solutions for Your Team"
343
855
  },
344
856
  {
345
- "audienceId": "test-1",
346
- "segmentId": "test-1-b",
347
- "value": "qwer"
857
+ "audienceId": "customer-type",
858
+ "segmentId": "small-business",
859
+ "value": "Grow Your Business with Powerful Tools"
860
+ },
861
+ {
862
+ "audienceId": "customer-type",
863
+ "segmentId": "individual",
864
+ "value": "Your Personal Productivity Hub"
348
865
  }
349
866
  ]
350
867
  }
351
868
  ```
352
869
 
353
- This will also affect the query you write to fetch data to be:
870
+ ### Querying with Custom Field Names
871
+
872
+ Update your GROQ queries to use the renamed fields:
354
873
 
355
874
  ```ts
356
- *[_type == "post"] {
357
- "title":coalesce(title.segments[audienceId == $audience && segmentId == $segment][0].value, title.default),
875
+ *[_type == "landingPage"] {
876
+ "headline": coalesce(
877
+ headline.segments[audienceId == $audience && segmentId == $segment][0].value,
878
+ headline.default
879
+ ),
358
880
  }
359
881
  ```
360
882
 
883
+ On your frontend, pass the audience and segment:
884
+
885
+ ```ts
886
+ const page = await client.fetch(query, {
887
+ audience: 'customer-type',
888
+ segment: userSegment, // e.g., 'enterprise', 'small-business', or 'individual'
889
+ })
890
+ ```
891
+
361
892
  ## License
362
893
 
363
894
  [MIT](LICENSE) © Jon Burbridge