Experience Decisioning
Adobe Journey Optimizer Code‑Based Experiences on an Edge Delivery Services Site
Code‑Based Offers on an Edge Delivery Services guide
This guide shows how to connect Adobe Journey Optimizer (AJO) Code‑Based Experiences to an Edge Delivery Services (EDS) page
Configuring AJO Code‑Based Offers on an Edge Delivery Services Page
This guide shows how to connect Adobe Journey Optimizer (AJO) Code‑Based Experiences to an Edge Delivery Services (EDS) page so that:
- Authors define an
offersblock in document authoring. - Adobe Tags (Web SDK) discovers all offer surfaces on the page and calls AJO once.
- AJO Code‑Based Experiences return a JSON payload with personalization propositions for each surface.
offers.jsrenders those propositions into the EDS block.
All examples use this lab page, where the personalized offers appear below the hero:
https://edgepatterns.dev/labs/ajo-code-based-experience-eds
The surface below the hero is:
web://edgepatterns.dev/labs/ajo-code-based-experience-eds#offers-below-hero
Authoring: define the offers block and surface name
Authors work in document authoring (Docs/Word/SharePoint) and create an offers block using a table.
The first header cell defines the block type (offers).
The second header cell defines a surface key, which becomes part of the surface URI (for example, offers-below-hero).
Example authoring table:
After the EDS pipeline processes this, the relevant HTML looks like:
<div class="offers offers-below-hero">
<!-- block content -->
</div>
Here:
offersidentifies the block.offers-below-herois your surface key, which both Tags andoffers.jswill map to a full surface URI:
web://edgepatterns.dev/labs/ajo-code-based-experience-eds#offers-below-hero
You can repeat this pattern for more slots (e.g., offers-sidebar, offers-footer) by changing the value in brackets.
Adobe Tags: discover surfaces and call AJO
Next, use Adobe Tags with the AEP Web SDK to:
- Discover all
offersblocks on the page. - Derive their full surface URIs.
- Call
alloy("sendEvent")once with all surfaces. - Expose the returned personalization propositions to the page via a custom event.
2.1 Data Element: "AJO Surfaces"
Create a Custom Code Data Element, for example named AJO Surfaces, that:
- Scans all
.offersblocks. - Reads the second class as the surface key.
- Builds a full surface URI using the current path.
Example implementation:
const base = 'web://edgepatterns.dev';
const path = window.location.pathname;
const surfaces = [];
document.querySelectorAll('.offers').forEach((block) => {
const classes = Array.from(block.classList);
// Any class that is not "offers" is treated as the surface key
const key = classes.find((cls) => cls !== 'offers'); // e.g. "offers-below-hero"
if (!key) return;
const fullSurface = `${base}${path}#${key}`;
if (!surfaces.includes(fullSurface)) {
surfaces.push(fullSurface);
}
});
return surfaces;
On the lab page above, this will return:
[
"web://edgepatterns.dev/labs/ajo-code-based-experience-eds#offers-below-hero"
]
If you add more offers blocks with different second classes, they will automatically be included in this array.
Note
This can be done either via Custom Code using alloy("sendEvent", ...) directly, or by configuring a guided event in the Web SDK extension UI. If your Tags extension supports it, use Guided events → Request personalization. Adobe documents that this configuration locks the event type to Decisioning Proposition Fetch, enables rendering, and disables automatic display events for the top-of-page request — which is exactly the right behaviour for a code-based experience.
2.2 Rule: call AJO at page top
Create a rule such as "Personalization – Page top":
- Event:
Library Loaded (Page Top) - Action: Web SDK — Request personalization (or
propositionFetchvia customsendEvent).
From here on, the page will receive a single personalization response per load, with propositions for each surface.
2.3 Rule: Send Event Complete
The Send event complete event runs after successful Web SDK sendEvent calls, not only after decisioning.propositionFetch. We therefore don't use this event to identify which request completed. Instead, we check whether the response contains personalization propositions and expose those propositions to the page.
Create a rule that listens to the Web SDK Send Event Complete event and runs the following custom code:
var propositions = Array.isArray(event.propositions)
? event.propositions
: [];
if (!propositions.length) {
return;
}
var detail = {
propositions: propositions
};
window.__aepPersonalization = detail;
window.dispatchEvent(new CustomEvent('aep:personalization', {
detail: detail
}));
When propositions are returned, the Launch rule stores the latest response on window.__aepPersonalization and dispatches an aep:personalization custom event. The retained value handles EDS blocks that initialize after the Web SDK response has already arrived.
This creates a small but important boundary between Adobe Web SDK and the EDS components. Launch is responsible for retrieving and exposing personalization data; individual EDS blocks decide whether a returned proposition belongs to them. That separation is one of the strongest ideas in this architecture — it keeps the Web SDK integration generic and the rendering logic entirely inside the component.
Why retain the latest response?
EDS blocks load asynchronously. Web SDK can return personalization before a block's JavaScript has finished loading. A browser CustomEvent is not replayed for listeners registered later, so the latest proposition response is also retained on window.__aepPersonalization and checked when the block initializes. This is a genuinely useful implementation pattern for any event-driven personalization on an async-loaded page.
AJO: configure the Code‑Based Experience and surfaces
On the Journey Optimizer side, you need:
- A Code‑Based channel configuration with matching surfaces.
- A Code‑Based Experience that emits JSON.
- Experience Decisioning policy to select offers.
This follows the model described in the "Code‑based experience", "Configure code‑based channel", "Get started with code‑based experiences", and "Code‑based experience prerequisites" documentation on Experience League.
3.1 Code‑Based channel and surfaces
-
Go to Administration → Channels → Code‑based channel.
-
Create a Web configuration.
-
Define surfaces that match what your page sends. For the lab page:
web://edgepatterns.dev/labs/ajo-code-based-experience-eds#offers-below-hero
The string must match exactly what Tags builds from the block classes and the URL path.
You can optionally define wildcard surfaces (for example, wildcard:web://edgepatterns.dev/*#offers-below-hero) if you want to reuse the same slot across multiple pages.
3.2 Code‑Based Experience with JSON output
Create a Code‑Based Experience (campaign or journey):
-
Channel configuration: select the Code‑Based channel where you defined your web surfaces.
-
Content format: choose JSON so the Edge returns structured data instead of HTML.
-
Targeting / Experience Decisioning:
- Optionally attach an Experience Decisioning policy to the surface to select offers based on profile, context, or ML ranking.
- Limit to three items if you want exactly three offers below the hero.
Shape your template so that, when the Edge responds to the Web SDK sendEvent, the Web SDK event.propositions array contains entries like this:
[
{
"scope": "web://edgepatterns.dev/labs/ajo-code-based-experience-eds#offers-below-hero",
"items": [
{
"id": "offer-1",
"data": {
"imageUrl": "https://example.com/img1.jpg",
"ctaUrl": "https://example.com/campaign-1",
"title": "Offer One",
"ctaText": "Learn more"
}
},
{
"id": "offer-2",
"data": {
"imageUrl": "https://example.com/img2.jpg",
"ctaUrl": "https://example.com/campaign-2",
"title": "Offer Two",
"ctaText": "Learn more"
}
},
{
"id": "offer-3",
"data": {
"imageUrl": "https://example.com/img3.jpg",
"ctaUrl": "https://example.com/campaign-3",
"title": "Offer Three",
"ctaText": "Learn more"
}
}
]
}
]
For JSON authoring in Code‑Based Experiences, you can follow the patterns described in "Delivering Personalization with JSON Content in Adobe Journey Optimizer" and "Create code‑based experiences".
The key contract for offers.js is:
-
proposition.scopeequals the surface URI (for example,web://edgepatterns.dev/...#offers-below-hero). -
proposition.itemsis an array. -
Each
item.datacontains:imageUrlctaUrltitlectaText
EDS: implement offers.js to render offers
You can find the offers.js implementation used on this page here: https://github.com/samircaus/da-elsie/blob/main/blocks/offers/offers.js
The EDS offers block is a surface-based, event-driven component. Its only job is to detect its surface, load the right renderer, reserve space, and then listen once for the aep:personalization custom event and delegate rendering to the surface-specific renderer.
Surface resolution and renderer loading
On decorate(block), the code derives the surface with getSurfaceNameFromBlock(block) by taking the first class that is neither offers nor block. That name is used to look up a loader in a RENDERERS map (e.g. offers-below-hero → dynamic import of ./renderers/offers-below-hero.js). The block loads the renderer's CSS and then dynamically imports the renderer module. If the surface is unknown or loading fails, the block logs and exits without rendering.
Surface matching
Each offers block derives its expected surface URI from the current URL and its block variation. It then filters the incoming propositions array to find only those intended for that surface:
const surfaceUri =
`web://edgepatterns.dev${window.location.pathname}#${surfaceName}`;
const propositions = detail.propositions
.filter((proposition) => proposition.scope === surfaceUri);
Each offers block ignores propositions intended for other surfaces. The flow looks like this:
offers offers-below-hero
↓
web://edgepatterns.dev/labs/...#offers-below-hero
↓
match proposition.scope
↓
render
That makes the architecture straightforward: the surface URI is deterministic from the block's class name and the page path, and the component is fully self-contained.
Personalization event and data mapping
The block subscribes to aep:personalization with { once: true }. When the event fires (or the retained window.__aepPersonalization value is found on initialization), it filters propositions by scope === surfaceUri. The first matching proposition's items are normalized with mapDecisionItemsToOffers() into a uniform shape (id, title, description, imageUrl, ctaText, ctaUrl), supporting both item.data and flat item structures. If there are offers, the block calls renderer.render(block, offers, {}).
Flow summary
The block clears the element, optionally calls renderer.reserveHeight(block) to avoid layout shift, and adds offers--ready. It then waits for the first aep:personalization event (or reads the retained value if it has already arrived), extracts and maps propositions for the current surface, and hands them to the renderer.
mermaid
flowchart TD
A[Web SDK] --> B[event.propositions]
B --> C[Launch code]
C --> D[aep:personalization]
C --> E[window.__aepPersonalization]
D --> F[Offers block]
E --> F
F --> G{Surface matches?}
G -->|Yes| H[Render offer]
G -->|No| I[Ignore]
So the logic is: decorate → resolve surface → load renderer (and CSS) → reserve height → listen once for personalization (or read retained value) → filter by scope → map to offers → render.
This implementation assumes:
- The Tags rule has already called
alloy("sendEvent", { personalization: { surfaces } }). - A custom
aep:personalizationevent is fired with the full propositions array (and/or retained onwindow.__aepPersonalization). - The AJO JSON follows the shape described in section 3.2.
If you later introduce different rendering "types", you can:
- Use different surface keys (
offers-below-hero-grid,offers-sidebar-list), and/or - Add additional classes (for example,
offers-grid,offers-carousel) and branch insiderenderOffersbased onblock.classList.
Recap and extension ideas
With this setup you get:
-
Simple authoring: Authors add an
offerstable with a second header cell (for example,offers-below-hero). That defines the slot. -
Single Edge call: Tags discovers all
.offersblocks from the DOM and sends all surfaces in onepersonalization.surfacesarray via the Web SDK. -
Clear contract with AJO:
- Surfaces are URIs of the form
web://domain/path#fragment. - JSON output is a
propositionsarray; each proposition hasscopeanditems[*].data.
- Surfaces are URIs of the form
-
Decoupled rendering: Launch exposes personalization data; each
offersblock decides independently whether a returned proposition belongs to it. AJO campaigns can change offers without touching the component code.
From here, you can extend the same pattern to additional slots:
- Add more
offersblocks per page with different second header cells (for example,offers-sidebar,offers-footer). - Let the
AJO SurfacesData Element pick them all up. - Configure corresponding surfaces in AJO.
- Reuse the same
offers.jsto render each slot independently.
This approach builds directly on the patterns described in the Journey Optimizer "Code‑based experience", "Configure code‑based channel", and "Get started with code‑based experiences" documentation, as well as the AEP Web SDK "Using Adobe Journey Optimizer with the Experience Platform Web SDK" and "personalization in sendEvent" guidance, and fits naturally into the Edge Delivery Services block model.