A/B tests
How it works
Configure A/B-testable types
Document types can be configured to support editor-defined A/B tests. This is done by adding the custom.abTest property to the document type definition. Here's an example:
export const myDocumentType = documentSchema({
name: 'myDocumentType',
type: 'document',
custom: {
abTest: true,
// etc...
},
// ...
)}Define the A/B test schema
- A/B tests are separate documents in Sanity, which include:
testId: a unique identifier for logging & analyzing the test. Read-only.controlDocument: reference to a document with routevariants: array of:allocation: 0-100 percentage of users to show this variant todocument: reference to a modified duplication of the control document
- Only documents with
custom.abTest: truecan be used as control or variants - Documents with A/B tests include a
abTest__metaproperty, an object with:docType:controlorvariant- If a variant,
variantKey: the ID of the variant that is logged to analytics
- The A/B test schema is added in
/app/sanity/schemas/documents/index.tsand it takes in the array of all other document types
Refer to app/sanity/schemas/abTest.ts for implementation details.
Query variants for current route
A/B-testable types should wrap their document queries with buildAbTestFragment to fetch existing A/B tests for the current queried document:
const PAGE_ROUTE_QUERY = /* groq */ `
"routeData": *[${DOC_ID_FILTER}][0] {
${buildAbTestFragment(`
...,
${TRANSLATIONS_FRAGMENT}
body[] {
${BLOCKS_BODY_FRAGMENT}
},
`)}
}`buildAbTestFragment works by querying any abTest that includes the current document as its controlDocument and using the routeDataFragment to fetch the data for every variant:
/**
* Returns the GROQ fragment for fetching the `abTest` property of `MinimalRouteData`
*/
function buildAbTestFragment(routeDataFragment: string) {
return `
"abTest": *[_type == "abTest" && ${NON_PRIVATE_FILTER} && controlDocument._ref == ^._id][0]{
testId,
variants[] {
_key,
allocation,
_key != "control" => {
document->{
${routeDataFragment}
},
},
},
},
${routeDataFragment}
`
}Render a variant and persist the choice on a user-basis
The app/routing/pickAbTest.ts function will pick a variant for the current route if there's any A/B test for it. It will:
-
Parse the user's cookies and check if they have a saved variant key that is valid
-
If so, return the variant document for that key
-
Otherwise, run
chooseVariantfrom@tinloof/js-toolkitapp/routing/pickAbTest.tsconst chosenVariant = savedVariantKey && allVariantKeys.includes(savedVariantKey) ? (variants.find( (v) => v._key === savedVariantKey, ) as (typeof variants)[0]) : chooseVariant(abTest.variants) -
Save the chosenVariant's key to user's cookies
-
Modify the route data to include the chosen variant's data and simplify the
abTestobject with just the data we need for analytics:app/routing/pickAbTest.tsconst routeData = { ...(chosenVariant.document || data.routeData), abTest: { testId: abTest.testId, chosenVariantKey, }, } -
Return that to
routeLoader, which will use this as the final data- And skip cache if we have an A/B test
app/routing/routeLoader.ts// ===== 4. PICK A/B TEST IF ANY IS CONFIGURED ===== const [dataWithAbTest, abTestCookie] = await pickAbTest(data, context) // Don't cache test variations or preview data const skipCache = !!(abTestCookie || previewState !== PreviewState.OFF) // ===== RETURN FINAL RESULT ===== return cachedResponse(dataWithAbTest, { cdnCacheDuration: skipCache ? 0 : undefined, //... })
In the Shopify Template, there's an extra step after A/B tests to fetch referenced nodes from Shopify's GraphQL
Log the results to Google Analytics
Even if you're not using Google Analytics, you can learn from this implementation for whichever provider you end-up using.
If using Google Analytics, you'll need to set the gaTrackingId in app/config.ts by populating the PUBLIC_GA_TRACKING_ID environment variable in .env.
The app/components/GoogleAnalytics.tsx component loads the scripts and initializes the gtag function. After mounting, it'll log the current variant through a set_variant event, if there's any active test in the current page:
const shouldTrack =
// Must having a tracking ID
!!config.gaTrackingId &&
// And don't track in development
env.NODE_ENV !== 'development' &&
// or in the Sanity studio
!pathname.startsWith('/manage')
// LOGGING A/B TEST VARIANTS
const { testId, chosenVariantKey } = props.routeData?.abTest || {}
useEffect(() => {
if (!testId || !chosenVariantKey || !shouldTrack) return
gtagEvent('set_variant', {
ab_test_id: testId,
ab_variant_key: chosenVariantKey,
// Don't let Analytics treat this as an interaction event that could skew bounce rate calculation
non_interaction: true,
})
}, [testId, chosenVariantKey, shouldTrack])Analyze the results
The parts the toolkit handles
@tinloof/sanity-toolkit exposes a custom component for editing tests more easily and reliably.
When adding a variant, the component will:
- modify
controlDocumentto add aabTest__meta: { docType: "control" }property - duplicate
controlDocumentto use as the variant withabTest__meta: { docType: "variant", variantKey: generateVariantKey() } - add a reference to this new document to the A/B test document's
variantsarray
When commiting a variant:
- patch
controlDocumentto removeabTest__metaand include the changed data from the variant - delete all variant documents
- delete the A/B test document's
variantsarray
It also includes an AbTestDeleteAction document action (opens in a new tab) that deletes variants and removed abTest__meta from the control document before deleting the A/B test document itself.
In the pages browser and in collection indexes, we don't display variant documents by including NON_VARIANT_FILTER:
const NON_VARIANT_FILTER = 'abTest__meta.docType != "variant"'Finally, @tinloof/js-toolkit exposes a chooseVariant helper that properly distributes the choice of variants according to their accumulation, with statistical significance.
The toolkit includes a set of tools to run A/B tests. The main class is ABTest which is a subclass of Experiment. It is used to run A/B tests and to analyze the results. The ABTest class is a subclass of Experiment and inherits all its methods. The ABTest class has the following additional methods: