The AEM Experimentation plugin helps you quickly set up experimentation and segmentation on your AEM project. It is currently available to customers in collaboration with AEM Engineering via co-innovation VIP Projects. To implement experimentation or personalization use-cases, please reach out to the AEM Engineering team in the Slack channel dedicated to your project.
Note: We are adding new support for the contextual experimentation rail UI. This is still under development. Feel free to reach out if you have any questions via email: aem-contextual-experimentation@adobe.com.
The AEM Experimentation plugin supports:
- 👥 serving different content variations to different audiences, including custom audience definitions for your project that can be either resolved directly in-browser or against a trusted backend API.
- 💸 serving different content variations based on marketing campaigns you are running, so that you can easily track email and/or social campaigns
- 📈 running A/B test experiments on a set of variants to measure and improve the conversion on your site. This works particularly with our 💹 RUM conversion tracking plugin.
- 🛡️ privacy-compliant experimentation with built-in consent management support for GDPR, CCPA, and other privacy regulations
- 🚀 easy simulation of each experience and basic reporting leveraging in-page overlays
Add the plugin to your AEM project by running:
git subtree add --squash --prefix plugins/experimentation git@github.com:adobe/aem-experimentation.git v2If you later want to pull the latest changes and update your local copy of the plugin:
git subtree pull --squash --prefix plugins/experimentation git@github.com:adobe/aem-experimentation.git v2If you prefer using https links you'd replace git@github.com:adobe/aem-experimentation.git in the above commands by https://github.com/adobe/aem-experimentation.git.
- plugins/experimentation - Add this folder containing the experimentation engine plugins (see Installation section above)
- scripts/experiment-loader.js - Add this script to handle experiment loading
- scripts/scripts.js - Modify this script with the configuration
Create a new file scripts/experiment-loader.js with the following content:
/**
* Checks if experimentation is enabled.
* @returns {boolean} True if experimentation is enabled, false otherwise.
*/
const isExperimentationEnabled = () => document.head.querySelector('[name^="experiment"],[name^="campaign-"],[name^="audience-"],[property^="campaign:"],[property^="audience:"]')
|| [...document.querySelectorAll('.section-metadata div')].some((d) => d.textContent.match(/Experiment|Campaign|Audience/i));
/**
* Loads the experimentation module (eager).
* @param {Document} document The document object.
* @param {Object} config The experimentation configuration.
* @returns {Promise<void>} A promise that resolves when the experimentation module is loaded.
*/
export async function runExperimentation(document, config) {
if (!isExperimentationEnabled()) {
window.addEventListener('message', async (event) => {
if (event.data?.type === 'hlx:experimentation-get-config') {
event.source.postMessage({
type: 'hlx:experimentation-config',
config: { experiments: [], audiences: [], campaigns: [] },
source: 'no-experiments'
}, '*');
}
});
return null;
}
try {
const { loadEager } = await import(
'../plugins/experimentation/src/index.js'
);
return loadEager(document, config);
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to load experimentation module (eager):', error);
return null;
}
}
/**
* Loads the experimentation simulation UI (lazy).
* The simulation panel is an authoring aid, so this is a no-op in production —
* the coarse check below avoids even fetching the plugin there, and the plugin
* re-checks authoritatively before showing anything.
* @param {Document} document The document object.
* @param {Object} config The experimentation configuration.
* @returns {Promise<void>} A promise that resolves when the simulation UI is loaded.
*/
export async function runExperimentationLazy(document, config) {
const { host, hostname, origin } = window.location;
const isPreview = hostname === 'localhost'
|| hostname.endsWith('.page')
|| (typeof config.isProd === 'function' && !config.isProd())
|| (config.prodHost && ![host, hostname, origin].includes(config.prodHost));
if (!isPreview) {
return null;
}
try {
const { loadLazy } = await import(
'../plugins/experimentation/src/index.js'
);
return loadLazy(document, config);
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to load experimentation module (lazy):', error);
return null;
}
}Note: Add the following line to your
head.htmlto preload the experiment loader script:<link rel="modulepreload" href="/scripts/experiment-loader.js" />
Add the following import and configuration at the top of your scripts/scripts.js:
import {
runExperimentation,
runExperimentationLazy,
} from './experiment-loader.js';
const experimentationConfig = {
prodHost: 'www.mysite.com', // add your prodHost here, otherwise we will show mock data
audiences: {
mobile: () => window.innerWidth < 600,
desktop: () => window.innerWidth >= 600,
// define your custom audiences here as needed
},
};Then, add the following line early in your loadEager() function:
async function loadEager(doc) {
// ... existing code ...
await runExperimentation(doc, experimentationConfig);
// ... rest of your code ...
}And add the following line to your loadLazy() function to enable the simulation
panel (see Enabling the simulation panel below):
async function loadLazy(doc) {
// ... existing code ...
await runExperimentationLazy(doc, experimentationConfig);
// ... rest of your code ...
}When running experiments during short periods (i.e. a few days or 2 weeks) or on low-traffic pages (<100K page views a month), it is unlikely that you'll reach statistical significance on your tests with the default RUM sampling. For those use cases, we recommend adjusting the sampling rate for the pages in question to 1 out of 10 instead of the default 1 out of 100 visits.
Edit your html <head> and set configure the RUM sampling like:
<meta name="experiment" content="...">
...
<!-- insert this script tag before loading aem.js or lib-franklin.js -->
<script>
window.RUM_SAMPLING_RATE = document.head.querySelector('[name^="experiment"],[name^="campaign-"],[name^="audience-"]')
|| [...document.querySelectorAll('.section-metadata div')].some((d) => d.textContent.match(/Experiment|Campaign|Audience/i))
? 10
: 100;
</script>
<script type="module" src="/scripts/aem.js"></script>
<script type="module" src="/scripts/scripts.js"></script>Then double-check your aem.js file around line 20 and look for:
const weight = new URLSearchParams(window.location.search).get('rum') === 'on' ? 1 : defaultSamplingRate;If this is not present, please apply the following changes to the file: https://github.com/adobe/helix-rum-js/pull/159/files#diff-bfe9874d239014961b1ae4e89875a6155667db834a410aaaa2ebe3cf89820556
There are various aspects of the plugin that you can configure via the experimentationConfig object.
You have already seen the audiences option in the examples above, but here is the full list we support:
const experimentationConfig = {
// Lets you configure the prod environment.
// (prod environments do not get the pill overlay)
prodHost: 'www.mysite.com',
// if you have several, or need more complex logic to toggle pill overlay, you can use
isProd: () => !window.location.hostname.endsWith('hlx.page')
&& window.location.hostname !== ('localhost'),
// the storage type used to persist data between page views
// (for instance to remember what variant in an experiment the user was served)
storage: window.sessionStorage,
/* Audiences related properties */
// See more details on the dedicated Audiences page linked below
audiences: {},
audiencesMetaTagPrefix: 'audience',
audiencesQueryParameter: 'audience',
/* Campaigns related properties */
// See more details on the dedicated Campaigns page linked below
campaignsMetaTagPrefix: 'campaign',
campaignsQueryParameter: 'campaign',
/* Experimentation related properties */
// See more details on the dedicated Experiments page linked below
experimentsMetaTagPrefix: 'experiment',
experimentsQueryParameter: 'experiment',
/* Fragment experiment needs redecoration */
// See more details below
decorationFunction: (el) => {
/* handle custom decoration here, for example: */
buildBlock(el);
decorateBlock(el);
},
/* Which simulation UI to wire up in preview/dev (see the section below) */
// - 'auto' (default): wire up the AEM Sidekick panel if/when the Sidekick is present
// - 'sidekick': same as 'auto', but explicit
// - 'universal-editor': the panel is delivered as a UE extension, so stay out of the way
// - false: do not load any simulation UI
simulationUI: 'auto',
};For detailed implementation instructions on the different features, please read the dedicated pages we have on those topics:
Cases of passing decorationFunction
Fragment replacement is handled by async observer, which may execute before or after default decoration complete. So, you need to provide a decoration method to redecorate. There are several common cases:
- Have a selector for an element inside a block and the block needs to be redecorated => sample code above
- Have a
.blockselector and need to redecorate => switch block status to"loading"and callloadBlock(el) - Have a
.sectionselector and need to redecorate => calldecorateBlocks(el) - Have a
mainselector and need to redecorate => calldecorateMain(el)
Starting with v2, the plugin no longer injects its own in-page overlay to simulate and switch between experiment variants. That panel now ships as a micro-frontend (MFE) that Adobe's AEM Sidekick browser extension opens on demand.
The plugin loads and toggles that panel for you from loadLazy() — you do not need to copy any
listener script into your project or add anything to head.html. Make sure you've wired
runExperimentationLazy into your scripts.js loadLazy() as shown in
Step 2 above. The panel only loads in preview/development
environments, never in production, and only when the Sidekick is present (see the simulationUI
option to change this — for example in the Universal Editor, where the panel is delivered as a UE
extension instead).
Note on performance and preview timing. The panel's UI and MFE loader live in a separate
simulation.jschunk thatloadLazypulls in via a dynamicimport()only in preview/development, so production pages never download or parse any simulation code. The small, UI-lesspostMessagehandshake the panel talks over is the one exception: it's registered eagerly (preview-only, fromloadEager) so that a Sidekick bookmarklet that injects the panel early in page load still finds a listener. Bumping the plugin therefore keeps the preview handshake working exactly as before — no migration needed — while still keeping the heavy panel UI off the production path.
The one thing the plugin can't do for you is register the Sidekick toolbar button, since that lives
in your project's Sidekick config, not in page code. Add the following plugin entry to
tools/sidekick/config.json in your project (a copy also lives at
documentation/sidekick/config.json):
{
"plugins": [
{
"id": "aem-experimentation",
"titleI18n": {
"en": "Experimentation"
},
"environments": [
"preview"
],
"includePaths": [
"**.docx**"
],
"event": "aem-experimentation-sidekick"
}
]
}Note: if your project has been migrated to AEM's unified ("helix5") config format — for instance via Experience Workspace — committing this file alone is not enough. Sidekick reads its config from your persisted site config (
https://admin.hlx.page/config/{org}/sites/{site}.json), not a live read oftools/sidekick/config.jsonoff the code bus, so you need to merge a matchingsidekick.pluginsarray into that persisted config directly. Note also that this admin API's verbs are inverted from typical REST conventions:PUTonly creates a config that doesn't exist yet (and fails withconfig already existsif one is already there) —POSTis what updates an existing config.
Once the button is registered and runExperimentationLazy is wired up, clicking the
"Experimentation" button in Sidekick opens the panel. A shared simulation link
(?experiment=<experiment-id>/<variant-name>) also opens the panel automatically on page load.
The experimentation plugin exposes APIs that allow you to integrate with analytics platforms and other 3rd-party libraries. The plugin exposes experiment data through two mechanisms:
- Events - React immediately when experiments are applied (V2 only)
- Global Objects - Access complete experiment details after page load
The plugin provides consent management APIs for privacy compliance. Experiments can be configured to require user consent before running.
APIs:
import {
isUserConsentGiven,
updateUserConsent
} from './plugins/experimentation/src/index.js';
// Check if user has consented to experimentation
const hasConsent = isUserConsentGiven();
// Integrate this with your consent management platform events to track the user's choice
updateUserConsent(true); // or false to revoke consentRequiring consent for an experiment:
Add the Experiment Requires Consent metadata property:
| Metadata | |
|---|---|
| Experiment | Hero Test |
| Experiment Variants | /variant-1, /variant-2 |
| Experiment Requires Consent | true |
Implementation:
You can integrate consent management in two ways:
- In your
experiment-loader.js(recommended) - keeps all experimentation code together - In your
scripts.js- if you need consent for other purposes beyond experimentation
Recommended: Integrate in experiment-loader.js
// experiment-loader.js
import {
updateUserConsent,
isUserConsentGiven,
} from '../plugins/experimentation/src/index.js';
/**
* Initialize consent management
* Choose ONE of the setup functions based on your CMP (Consent Management Platform)
*
* IMPORTANT: These are example implementations. Please:
* 1. Verify the consent categories match your OneTrust/Cookiebot configuration
* 2. Test thoroughly in your environment
* 3. Consult with your legal/privacy team about consent requirements
*/
function initConsent() {
// OPTION 1: OneTrust
function setupOneTrustConsent() {
// Step 1: Bridge OneTrust's callback to dispatch a custom event
window.OptanonWrapper = function() {
const activeGroups = window.OnetrustActiveGroups || '';
const groups = activeGroups.split(',').filter(g => g);
window.dispatchEvent(new CustomEvent('consent.onetrust', {
detail: groups
}));
};
// Step 2: Listen for the custom event
function consentEventHandler(ev) {
const groups = ev.detail;
const hasConsent = groups.includes('C0003') // Functional Cookies
|| groups.includes('C0004'); // Targeting Cookies
updateUserConsent(hasConsent);
}
window.addEventListener('consent.onetrust', consentEventHandler);
}
// OPTION 2: Cookiebot
function setupCookiebotConsent() {
function handleCookiebotConsent() {
const preferences = window.Cookiebot?.consent?.preferences || false;
const marketing = window.Cookiebot?.consent?.marketing || false;
updateUserConsent(preferences || marketing);
}
window.addEventListener('CookiebotOnConsentReady', handleCookiebotConsent);
window.addEventListener('CookiebotOnAccept', handleCookiebotConsent);
}
// OPTION 3: Custom Consent Banner
function setupCustomConsent() {
document.addEventListener('consent-updated', (event) => {
updateUserConsent(event.detail.experimentation);
});
}
// Choose ONE:
setupOneTrustConsent(); // or setupCookiebotConsent() or setupCustomConsent()
}
export async function runExperimentation(document, config) {
if (!isExperimentationEnabled()) {
return null;
}
// Initialize consent BEFORE loading experimentation
initConsent();
const { loadEager } = await import('../plugins/experimentation/src/index.js');
return loadEager(document, config);
}
// Export consent functions for use elsewhere if needed
export { updateUserConsent, isUserConsentGiven };Your scripts.js stays clean - no consent code needed there!
Integrate in scripts.js
// scripts.js
import {
updateUserConsent,
isUserConsentGiven,
} from '../plugins/experimentation/src/index.js';
import { runExperimentation } from './experiment-loader.js';
// Setup consent (choose ONE based on your CMP)
function setupOneTrustConsent() {
// Step 1: Bridge OneTrust's callback to dispatch a custom event
window.OptanonWrapper = function() {
const activeGroups = window.OnetrustActiveGroups || '';
const groups = activeGroups.split(',').filter(g => g);
window.dispatchEvent(new CustomEvent('consent.onetrust', {
detail: groups
}));
};
// Step 2: Listen for the custom event
function consentEventHandler(ev) {
const groups = ev.detail;
const hasConsent = groups.includes('C0003') // Functional Cookies
|| groups.includes('C0004'); // Targeting Cookies
updateUserConsent(hasConsent);
}
window.addEventListener('consent.onetrust', consentEventHandler);
}
async function loadEager(doc) {
document.documentElement.lang = 'en';
decorateTemplateAndTheme();
// Initialize consent BEFORE running experiments
setupOneTrustConsent();
await runExperimentation(doc, experimentationConfig);
// ... rest of your code
}For detailed usage instructions and more examples, see the Experiments documentation.
Listen for the aem:experimentation event to react when experiments, campaigns, or audiences are applied:
document.addEventListener('aem:experimentation', (event) => {
console.log(event.detail);
});The event details will contain one of 3 possible sets of properties:
- For experiments:
{
type: 'experiment',
element: DOMElement, // the DOM element that was modified
experiment: 'experiment-name', // the experiment name
variant: 'variant-name' // the variant name that was served
}- For campaigns:
{
type: 'campaign',
element: DOMElement, // the DOM element that was modified
campaign: 'campaign-name' // the campaign that was resolved
}- For audiences:
{
type: 'audience',
element: DOMElement, // the DOM element that was modified
audience: 'audience-name' // the audience that was resolved
}You can leverage the following global JS objects:
// All experiments (page, section, fragment levels)
const allExperiments = window.hlx.experiments;
// All audiences (page, section, fragment levels)
const allAudiences = window.hlx.audiences;
// All campaigns (page, section, fragment levels)
const allCampaigns = window.hlx.campaigns;
// backward compatibility with V1
const experiment = window.hlx.experiment;
const audience = window.hlx.audience;
const campaign = window.hlx.campaign;- Array Structure:
window.hlx.experiments, window.hlx.audiences, and window.hlx.campaigns are each an array of objects containing:
[
{
type: 'page', // one of: page, section, fragment
el: DOMElement, // the DOM element that was modified
servedExperience: '/variant-url', // the URL for the content that was inlined (if any)
config: { /* see Complete Reference section below */ }
}
// ... more objects for section/fragment level modifications
]For Adobe Analytics, Target, and Adobe Journey Optimizer integration:
- Event-driven approach:
document.addEventListener('aem:experimentation', (event) => {
if (event.detail.type === 'experiment') {
const { experiment, variant } = event.detail;
// Choose your Adobe integration method below
}
});Option 1: Adobe Client Data Layer (works with all Adobe products via Tags)
document.addEventListener('aem:experimentation', (event) => {
if (event.detail.type === 'experiment') {
const { experiment, variant } = event.detail;
window.adobeDataLayer = window.adobeDataLayer || [];
window.adobeDataLayer.push({
event: 'experiment-applied',
experiment: {
id: experiment,
variant: variant
}
});
}
});Option 2: Web SDK with XDM (direct AEP + Analytics integration)
document.addEventListener('aem:experimentation', (event) => {
if (event.detail.type === 'experiment') {
const { experiment, variant } = event.detail;
if (window.alloy) {
alloy("sendEvent", {
xdm: {
eventType: "decisioning.propositionDisplay",
timestamp: new Date().toISOString(),
_experience: {
decisioning: {
propositions: [{
id: experiment,
scope: "page",
items: [{
id: variant,
schema: "https://ns.adobe.com/personalization/default-content-item"
}]
}],
propositionEventType: {
display: 1
}
}
}
},
data: {
__adobe: {
analytics: {
eVar1: experiment,
eVar2: variant,
events: "event1"
}
}
}
});
}
}
});- Global object approach:
if (window.hlx.experiment) {
const { id, selectedVariant } = window.hlx.experiment;
// Choose your Adobe integration method below
}Option 1: Adobe Client Data Layer (works with all Adobe products via Tags)
if (window.hlx.experiment) {
const { id, selectedVariant } = window.hlx.experiment;
window.adobeDataLayer = window.adobeDataLayer || [];
window.adobeDataLayer.push({
event: 'experiment-applied',
experiment: {
id: id,
variant: selectedVariant
}
});
}Option 2: Web SDK with XDM (direct AEP + Analytics integration)
if (window.hlx.experiment) {
const { id, selectedVariant } = window.hlx.experiment;
if (window.alloy) {
alloy("sendEvent", {
xdm: {
eventType: "decisioning.propositionDisplay",
timestamp: new Date().toISOString(),
_experience: {
decisioning: {
propositions: [{
id: id,
scope: "page",
items: [{
id: selectedVariant,
schema: "https://ns.adobe.com/personalization/default-content-item"
}]
}],
propositionEventType: {
display: 1
}
}
}
},
data: {
__adobe: {
analytics: {
eVar1: id,
eVar2: selectedVariant,
events: "event1"
}
}
}
});
}
}- Event-driven integration (recommended):
document.addEventListener('aem:experimentation', (event) => {
if (event.detail.type === 'experiment') {
const { experiment, variant } = event.detail;
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'experiment_view',
experiment_id: experiment,
experiment_variant: variant
});
}
});- Global object access:
if (window.hlx.experiment) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'experiment_view',
experiment_id: window.hlx.experiment.id,
experiment_variant: window.hlx.experiment.selectedVariant
});
}- Event-driven integration (recommended):
document.addEventListener('aem:experimentation', (event) => {
if (event.detail.type === 'experiment') {
const { experiment, variant } = event.detail;
window.utag_data = window.utag_data || {};
window.utag_data.cms_experiment = `${experiment}:${variant}`;
}
});- Global object access:
// Example from UPS implementation
if (window.hlx.experiment) {
window.utag_data = window.utag_data || {};
window.utag_data.cms_experiment = `${window.hlx.experiment.id}:${window.hlx.experiment.selectedVariant}`;
}- Customer responsibility: You implement the analytics integration in your project code
- Runtime only: Data is available at runtime - no backend integration provided
- Project-specific: Integration depends on your analytics setup and project structure
- Existing analytics required: Your analytics platform must already be implemented
Here's the complete experiment config structure available in window.hlx.experiment:
{
id: "experiment-name",
selectedVariant: "challenger-1",
status: "active",
variantNames: ["control", "challenger-1"],
audiences: ["mobile", "desktop"],
resolvedAudiences: ["mobile"],
requiresConsent: true, // whether this experiment requires user consent
run: true,
variants: {
control: { percentageSplit: "0.5", pages: ["/current"], label: "Control" },
"challenger-1": { percentageSplit: "0.5", pages: ["/variant"], label: "Challenger 1" }
}
}Note: For analytics integration, you typically only need
idandselectedVariant. The full config structure above is available if you need detailed experiment settings for custom logic.