(Almost) all the ways to use Experience API

(Almost) all the ways to use Experience API

March 2026

The Experience API's Choose endpoint is a single contract: you send user context, and you get decisions back. What you do with those decisions, and where you call Choose, is what makes the difference between architectures. Deciding the way to integrate with Experience API ends up affecting the velocity of the marketing team in iterating and personalizing the website, and the ability for the website’s code to evolve over years.

This article is a guide to the patterns we see in production across customers of Mastercard Dynamic Yield. Most customers use one or two of these. Some combine three to cover different parts of their site. You don't need all of them, but knowing they exist helps you pick the right ones for your stack. There are other integration patterns beyond what's covered here, but these are the most common ones we see and advise on using.

Why use the API?

There are a few recurring reasons teams choose Experience API as their implementation method, either alongside or instead of the Dynamic Yield script:

Performance. When you resolve personalization decisions before the page reaches the browser, there's no flicker, no layout shift, and a very lean response size. This is mostly true for server-side and edge-side API calls, but a client-side call starting early enough may be able to complete before the Dynamic Yield script downloads and initializes its data.

(In some circumstances, having all above-the-fold campaigns using Experience API, allows moving the script to asynchronous loading without business downsides.)

Native rendering. Choose returns a JSON. You render it with your own components, your own design system, and your own CSS. This is especially helpful in modern JS frameworks where interactivity is easier to achieve inside JS components, than with injected DOM elements and browser event subscriptions.

Change management. Because you own the rendering code, every structural change to how personalization appears on your site goes through your normal engineering workflow: code review, staging, version control. New variations and targeting rules are managed in Experience OS, but the rendering contract stays in your codebase.

Reaching extra environments. The API works anywhere you can make an HTTP call. Native mobile applications, highly regulated webpages (where dynamic marketing scripts can’t be used), kiosks, in-store screens, email rendering pipelines, and call centers are all channels where Dynamic Yield customers were able to add personalization at scale.

By understanding what your business’s reasons are for considering Experience API, you will be able to evaluate the patterns below.

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 580" width="700" height="580" role="img"><title>The patterns described in this article by their rendering phase and ownership model.</title>

  <!-- Grid lines at 25%, 50%, 75% -->
  <!-- Horizontal -->
  <line x1="60" y1="155" x2="640" y2="155" stroke="#e8e5e1" stroke-dasharray="4 4"/>
  <line x1="60" y1="250" x2="640" y2="250" stroke="#e8e5e1" stroke-dasharray="4 4"/>
  <line x1="60" y1="345" x2="640" y2="345" stroke="#e8e5e1" stroke-dasharray="4 4"/>
  <!-- Vertical -->
  <line x1="205" y1="60" x2="205" y2="440" stroke="#e8e5e1" stroke-dasharray="4 4"/>
  <line x1="350" y1="60" x2="350" y2="440" stroke="#e8e5e1" stroke-dasharray="4 4"/>
  <line x1="495" y1="60" x2="495" y2="440" stroke="#e8e5e1" stroke-dasharray="4 4"/>

  <!-- Axes -->
  <line x1="60" y1="60" x2="60" y2="440" stroke="#d1cdc7" stroke-width="1.5"/>
  <line x1="60" y1="440" x2="640" y2="440" stroke="#d1cdc7" stroke-width="1.5"/>

  <!-- Y-axis labels -->
  <text x="52" y="64" text-anchor="end" fill="#96918b" font-size="11">Earlier</text>
  <text x="52" y="444" text-anchor="end" fill="#96918b" font-size="11">Later</text>

  <!-- Y-axis title -->
  <text x="16" y="250" text-anchor="middle" fill="#777470" font-size="11"
        transform="rotate(-90, 16, 250)">
    When personalization is decided during page serving
  </text>

  <!-- X-axis labels -->
  <text x="60" y="460" text-anchor="start" fill="#96918b" font-size="11">
    Centralized, likely engineering
  </text>
  <text x="640" y="460" text-anchor="end" fill="#96918b" font-size="11">
    Democratized, likely marketing
  </text>

  <!-- X-axis title -->
  <text x="350" y="480" text-anchor="middle" fill="#777470" font-size="11">Ownership spectrum</text>

  <!-- Pattern dots and labels -->
  <!-- Positions computed from:
       cx = 60 + (x/100)*580
       cy = 60 + (y/100)*380
  -->

  <!-- Server-side -->
  <g id="simple-json">
    <circle cx="164.4" cy="117" r="7" fill="#ffe1d1" stroke="#f37338" stroke-width="2"/>
    <text x="164.4" y="104" text-anchor="middle" fill="#555250" font-size="10.5">Simple JSON</text>
  </g>

  <g id="cms-integration">
    <circle cx="350" cy="117" r="7" fill="#ffe1d1" stroke="#f37338" stroke-width="2"/>
    <text x="350" y="104" text-anchor="middle" fill="#555250" font-size="10.5">CMS Integration</text>
  </g>

  <g id="json-guided">
    <circle cx="303.6" cy="136" r="7" fill="#ffe1d1" stroke="#f37338" stroke-width="2"/>
    <text x="303.6" y="123" text-anchor="middle" fill="#555250" font-size="10.5">JSON-Guided</text>
  </g>

  <g id="html-server">
    <circle cx="466" cy="136" r="7" fill="#ffe1d1" stroke="#f37338" stroke-width="2"/>
    <text x="466" y="123" text-anchor="middle" fill="#555250" font-size="10.5">Templates (Server)</text>
  </g>

  <!-- Edge-side -->
  <g id="streaming">
    <circle cx="164.4" cy="174" r="7" fill="#ffab82" stroke="#cf4500" stroke-width="2"/>
    <text x="164.4" y="161" text-anchor="middle" fill="#555250" font-size="10.5">Streaming</text>
  </g>

  <g id="page-routing">
    <circle cx="222.4" cy="79" r="7" fill="#ffab82" stroke="#cf4500" stroke-width="2"/>
    <text x="222.4" y="66" text-anchor="middle" fill="#555250" font-size="10.5">Page Routing</text>
  </g>

  <!-- Client-side -->
  <g id="component-led">
    <circle cx="222.4" cy="307" r="7" fill="#f3f0ee" stroke="#96918b" stroke-width="2"/>
    <text x="222.4" y="294" text-anchor="middle" fill="#555250" font-size="10.5">Component-Led</text>
  </g>

  <g id="page-based">
    <circle cx="164.4" cy="280.4" r="7" fill="#f3f0ee" stroke="#96918b" stroke-width="2"/>
    <text x="164.4" y="267.4" text-anchor="middle" fill="#555250" font-size="10.5">Page-Based</text>
  </g>

  <g id="html-client">
    <circle cx="524" cy="333.6" r="7" fill="#f3f0ee" stroke="#96918b" stroke-width="2"/>
    <text x="524" y="320.6" text-anchor="middle" fill="#555250" font-size="10.5">Templates (Client)</text>
  </g>

  <g id="autonomous">
    <circle cx="593.6" cy="345" r="7" fill="#f3f0ee" stroke="#96918b" stroke-width="2"/>
    <text x="593.6" y="332" text-anchor="middle" fill="#555250" font-size="10.5">Autonomous</text>
  </g>

  <!-- Legend (embedded into same SVG) -->
  <g id="legend">
    <!-- Server-side -->
    <circle cx="160" cy="540" r="6" fill="#f37338" stroke="#f37338" stroke-width="2"/>
    <text x="174" y="544" fill="#555250" font-size="12" text-anchor="start">Server-side</text>

    <!-- Edge-side -->
    <circle cx="330" cy="540" r="6" fill="#cf4500" stroke="#cf4500" stroke-width="2"/>
    <text x="344" y="544" fill="#555250" font-size="12" text-anchor="start">Edge-side</text>

    <!-- Client-side -->
    <circle cx="500" cy="540" r="6" fill="#777470" stroke="#96918b" stroke-width="2"/>
    <text x="514" y="544" fill="#555250" font-size="12" text-anchor="start">Client-side</text>
  </g>
</svg>

A note on Recommendations, Experience Search, and PLP

Two things worth calling out before diving into the patterns, because they apply across all of them:

Recommendations are orthogonal to the patterns in this article. A RECS_DECISION comes back with the same JSON payload structure (or even an HTML/CSS/JS template) as any other campaign, but it also includes recommended product slots with enriched product data from your feed. Any of the rendering patterns below can carry a recommendations response. Whether you render recommendations server-side or client-side is its own decision, independent of how you handle other campaigns on the same page. Note that API Recommendations calls, including Search and PLP, are a little slower than API Custom Code campaigns. That makes it often better to render them client-side, to avoid blocking page load.

Experience Search and PLP campaigns support the same server-side, edge, and client-side patterns described in this article, just like any other campaign type. The Search endpoint supports targeting, A/B testing of merchandising rules, and all the context attributes of a regular Choose call.

Shopping Muse is also available through Experience API, and in fact the Dynamic Yield provided template, that most customers deploy Shopping Muse with, uses Experience API from the client-side to operate its logic.

Server-side

Your backend calls Choose during page rendering, before the response reaches the user. Decisions are included in the HTML.

The trade-off is straightforward: you add latency to your time-to-first-byte (the Choose call takes time), but the user sees a fully personalized page from the first paint. For above-the-fold content like hero banners, navigation personalization, and homepage layouts, this is usually worth it.

Another limitation here is that the page’s HTML can’t be cached. Each decision needs to be fresh, so websites that rely heavily on full-page caching need to either consider partial caching or moving the API calls to the CDN (edge-side) or browser (client-side).

Some managed hosting platforms, such as Shopify Liquid (the non-headless offering), don’t allow modification of the backend code and only provide frontend extensibility. Customers using these platforms could consider either the script-based implementation or client-side usage of Experience API.

Simple JSON selection

The most basic pattern. Call Choose, get a JSON payload with variable values (strings, image URLs, colors, Boolean flags), and plug them into your server-side template.

// Server-side: call Choose for a homepage hero banner
const response = await choose({
  selector: { names: ["homepage-hero"] },
  // ... user, session, context
});
const variation = response.choices[0].variations[0].payload.data;
// variation = { "headline": "Summer Sale", "ctaColor": "#E85D04", "imageUrl": "https://..." }

Your codebase already knows what a hero banner looks like. Dynamic Yield provides it with a headline, an image, and a color.

This is the pattern where change management is strongest. Adding a new variation in Experience OS means adding new values to existing keys and gives the marketing team that ability to do so independently and evolve over time and as sale events start and end. But changing the structure of the component (say, adding a countdown timer) requires an engineering change, with the usual review process and Git-based change history.

This approach is also often used for native mobile applications, where the app calls your servers for data and that data is personalized and tested on using calls to Experience API, or more straightforwardly proxying choices for the mobile app through your servers.

Simple JSON selection with a CMS integration

This is a variation on the pattern above, no pun intended. If your website uses a content management system (CMS) that Dynamic Yield has an integration with, you can have Experience API only return a content ID or slug in the CMS. This allows for the content creation and management to remain centralized in the CMS, while Dynamic Yield only selects which content to show.

Here, the JSON payload is a string or simple JSON with a reference to an entity in the CMS, and not usable on its own. Commonly, the Choose API will be called either before the CMS when it guides the data fetching from the CMS, or after the CMS to trim the CMS responses based on the personalization decisions.

You can find the guides for the various existing integrations here: Amplience, Contentful, Contentstack, Crownpeak, DatoCMS, and Storyblok

JSON-guided rendering

A step further. The JSON payload doesn't just carry display values, it carries structural decisions. The variation might say "show layout A with 3 columns" or "show layout B with a full-width video." Your server-side rendering logic branches based on the payload.

const variation = response.choices[0].variations[0].payload.data;
// variation = { "layout": "split", "leftPanel": "product-grid", "rightPanel": "editorial", ... }
if (variation.layout === "split") {
  renderSplitLayout(variation);
} else if (variation.layout === "fullwidth-video") {
  renderVideoLayout(variation);
}

This gives the marketing team a lot of flexibility in creating new experiences, especially with a recursive renderer and good JSON templates inside Experience OS. The rendering logic is still yours, but the marketing team is given relatively more freedom.

This pattern, although using client-side API calls, is how Dynamic Yield works best within Shopify checkout page.

HTML/CSS templates

This pattern may feel surprising at first. However, if your only concerns are rendering performance and limiting external scripts, it addresses those concerns while allowing the marketing team to move quickly and to use its own resources to develop new campaigns. Instead of your code owning the rendering, the marketing team manages the HTML, CSS, and JS for a campaign's creative directly in Experience OS using templates with variable placeholders.

When you call Choose, the response includes a template token. You fetch the template files from Dynamic Yield's CDN, merge in the variation's variable values using Mustache-style interpolation, and inject the assembled HTML into your page before serving it. The template files can be cached, as the tokens will change if the content changes, so that they’re loaded directly from memory or from a separate cache store.

import Mustache from "mustache";
Mustache.tags = ["${", "}"];
// 1. Call Choose — response includes variation data + template token
const variation = response.choices[0].variations[0];
const token = variation.payload.templateToken;
const data = variation.payload.data;
// 2. Fetch template files from DY CDN
const html = await fetch(`https://api-templates.dynamicyield.com/${sectionId}/${token}.html.template`);
const css = await fetch(`https://api-templates.dynamicyield.com/${sectionId}/${token}.css.template`);
// 3. Merge variables into template
const renderedHtml = Mustache.render(await html.text(), data);
const renderedCss = Mustache.render(await css.text(), data);
// 4. Inject into page before serving
...

Edge-side

The Choose call happens at the CDN or edge compute layer (Cloudflare Workers, Vercel Edge Functions, CloudFront Lambda@Edge, Akamai EdgeWorkers), between your origin server and the browser. Almost all modern CDNs have such functionality, although Cloudflare’s is the most mature.

Edge-side patterns give you personalization without adding latency to your origin server, and without the visible loading states of client-side rendering. They sit in a useful middle ground, but they do require an edge compute layer in your stack.

Streaming data to front-end

The edge function calls Choose and streams the response to the browser as it arrives. This is especially relevant if you're using partial prerendering (PPR), React Server Components, or chunked transfer encoding. See our Next.js 16 PPR article for a deep dive on this specific pattern.

The edge serves the static parts of the page immediately (navigation, footer, product details), and streams the personalized parts (recommendation widgets, targeted banners) as the Choose response comes back. The user sees a fast initial render, and personalized content fills in without a full-page delay.

This works well for recommendation widgets below the fold, secondary personalization, and any content that doesn't need to be part of the very first paint. Because the API call starts very early in the request’s lifecycle, is not affected by the browser’s internet connectivity to Experience API, and streams immediately to the browser, this approach tends to create very little flicker compared to any client-side approach. Its main advantage over server-side is allowing full-page caching, as the CDN handles the pages after the page cache.

Page routing

The edge function calls Choose to decide which page or experience to serve, rather than which content to place within a page.

// Edge function (pseudocode)
const response = await choose({
  selector: { names: ["landing-page-test"] },
  // ... user, session, context
});
const variant = response.choices[0].variations[0].payload;
// variant.data = { "route": "/landing/summer-v2" }
// Rewrite the request to the chosen page
return fetch(new Request(variant.data.route, request));

This is A/B testing, or targeted experiences, at the page level, invisible to the client. The user requests /landing and gets either the control or the variant (different internal URLs, different templates, or possibly different backends) without a redirect. The decision happens at the edge before the server even sees the request.

This pattern is useful for landing page tests, entirely different website flows, or gating access to a redesigned experience for a percentage of traffic or a specific segment.

Client-side

Experience API is sometimes mistakenly called the “server-side API”, but that’s a misnomer as it can be used equally by server code and client code. The requests and responses are identical in both cases[1]. Note that there are separate hostnames (e.g., direct.dy-api.com) and API keys when using it from browsers and mobile applications. You get more flexibility (components can independently fetch their own personalization), but the user may see a loading state or content shift as the personalized content arrives.

Component-led rendering with JSON

Individual components on the page each request their own personalization by selector name, make the call, and render themselves with the JSON response.

// React example — a self-contained personalized component
function HeroBanner() {
  const [variation, setVariation] = useState(null);
  useEffect(() => {
    dyChoose("homepage-hero")
      .then(choice => setVariation(choice.variations[0].payload));
  }, []);
  if (!variation) return <HeroBannerSkeleton />;
  return <Banner headline={variation.data.headline} image={variation.data.imageUrl} />;
}

This maps naturally to SPAs and micro-frontend architectures where each team owns their component. The risk is that multiple independent Choose calls from one page each add a network round-trip and count against your rate limit.

Batching solves the duplication of calls. Instead of each component calling Choose directly, each component registers its selector with a shared batcher. The batcher collects pending requests over a short window (say, 100ms), sends them all in a single Choose call, and distributes the results back to the requesting components.

The components don't need to know about each other, and the list of selectors in the batched call is naturally determined by which components are on the page. Adding or removing a component automatically changes what is requested, without updating any central configuration.

From the component's perspective, the usage looks almost identical to a direct call:

// A shared batcher instance, created once for the page
const batcher = new SelectorBatcher<ChoicePayload>(
  (selectors) => choose({ selector: { names: selectors }, /* ... */ }),
  100 // debounce window in ms
);
function HeroBanner() {
  const [variation, setVariation] = useState(null);
  useEffect(() => {
    batcher.requestSelectors(["homepage-hero"])
      .then(choice => setVariation(choice.variations[0].payload));
  }, []);
  if (!variation) return <HeroBannerSkeleton />;
  return <Banner headline={variation.data.headline} image={variation.data.imageUrl} />;
}

An example implementation of SelectorBatcher is provided below:

type SelectorResult<T> = Record<string, T>;
type SelectorRequest<T> = {
  selectors: string[];
  resolve: (result: SelectorResult<T>) => void;
  reject: (error: any) => void;
};
export class SelectorBatcher<T> {
  private pendingRequests: SelectorRequest<T>[] = [];
  private selectorSet: Set<string> = new Set();
  private debounceTimeout: NodeJS.Timeout | null = null;
  constructor(
    private fetchSelectors: (selectors: string[]) => Promise<SelectorResult<T>>,
    private debounceMs: number = 100
  ) {}
  requestSelectors(selectors: string[]): Promise<SelectorResult<T>> {
    return new Promise((resolve, reject) => {
      this.pendingRequests.push({ selectors, resolve, reject });
      selectors.forEach((s) => this.selectorSet.add(s));
      this.scheduleBatch();
    });
  }
  private scheduleBatch() {
    if (this.debounceTimeout) return;
    this.debounceTimeout = setTimeout(() => {
      this.executeBatch();
    }, this.debounceMs);
  }
  private async executeBatch() {
    const uniqueSelectors = Array.from(this.selectorSet);
    this.selectorSet.clear();
    this.debounceTimeout = null;
    try {
      const results = await this.fetchSelectors(uniqueSelectors);
      for (const { selectors, resolve } of this.pendingRequests) {
        const filteredResults: SelectorResult<T> = {};
        selectors.forEach((s) => {
          if (s in results) {
            filteredResults[s] = results[s];
          }
        });
        resolve(filteredResults);
      }
    } catch (error) {
      for (const { reject } of this.pendingRequests) {
        reject(error);
      }
    } finally {
      this.pendingRequests = [];
    }
  }
}

Page-based rendering with JSON

Rather than having components fetch their own data, you hoist the Choose call to the top-level code of the page. When a category page loads, for example, its page-level code requests all the campaigns it needs in a single call and passes the results down to the components that render them.

This is the most common implementation for native mobile applications.

// Page-level code for a category page
const response = await choose({
  selector: { names: ["category-hero", "category-recs", "promo-bar"] },
  // ...
});
// Pass results down to components
renderPage({
  hero: findChoice(response, "category-hero"),
  recs: findChoice(response, "category-recs"),
  promo: findChoice(response, "promo-bar"),
});

This is simpler than the batching approach because there's no coordination mechanism to build. You always know exactly one Choose call will be made, and the results are available before components start rendering.

The trade-off is that the page-level code must know about every personalized component on the page. If a component is added or removed, you need to update the selector list in the page-level code. In teams where different people own different components, this can become a coordination bottleneck.

HTML/CSS templates (client-side)

The same template mechanism as the server-side pattern, but the browser fetches and assembles the templates after page load.

The Choose response includes the template token. Client-side JavaScript fetches the HTML/CSS/JS from the Dynamic Yield template CDN, merges the variable values, and injects the result into the DOM. This is the pattern documented in the client-side template code examples.

The same change management trade-off applies: the marketing team owns the creative, and updates happen without a deploy. The additional consideration with the client-side version is that the template renders after page load, so there's a visible content insertion. For below-the-fold elements or overlays, this is usually fine. For above-the-fold hero content, the server-side variant avoids the visible pop-in.

One practical challenge in React-based applications (or any framework with virtual DOM reconciliation) is that framework re-renders can overwrite injected HTML. Dynamic Yield's own script-based Visual Edit campaigns handle this using a MutationObserver to detect when injected changes are removed and re-apply them. A similar pattern can be useful here: observe the target container, and if the framework replaces it during reconciliation, re-inject the rendered template, without making a new Choose call.

Autonomous execution

In all the client-side patterns above, your code decides where and when to render personalized content. This pattern goes further: you call the API and let the response itself drive when, where, and what gets rendered. This is a high-effort implementation, as your code needs to be highly flexible to support many use cases, but allows the marketing team to drive campaigns independently with guardrails defined by the code.

The idea is to loosely imitate what Dynamic Yield's script does internally, but using your own code and the Experience API. You define a set of selectors or selector groups that the page might need, call Choose, and then have a lightweight execution layer that reads each variation's payload (which might include target selectors, display conditions, or trigger rules) and acts on it. Components appear, overlays trigger, content replaces placeholders, all driven by what the API returns rather than by hardcoded rendering logic. Campaigns may return no decision if their targeting conditions don’t match, and the client-side code would simply ignore them if so; this allows campaigns to only apply to certain audiences or to be progressively rolled out.

This gives the team managing campaigns in Experience OS a high degree of autonomy over what happens on the page, without the Dynamic Yield script being involved. It requires more upfront architecture work than the other client-side patterns, because you're effectively building a mini rendering engine. But for teams that want the control of API integration with the flexibility of script-like campaign deployment, it can be a good fit.

This pattern is explained briefly in the Experience APIs Best Practices article.

Mixing and matching

Most production implementations combine patterns. The most common setup is:

Server-side JSON for the hero banner and navigation personalization (no flicker, above the fold, critical path)

Client-side component-led or page-based JSON for below-the-fold treatments and overlays (marketing team creates new overlay campaigns without touching the rendering pipeline)

Recommendations on whichever side is easier for you: some teams render recommendations server-side because they want them in the initial HTML; others load them client-side because the widget is below the fold and they'd rather not add to their time-to-first-byte or rather not to build and maintain the templates in-house.

The principle is to use the pattern that matches the architectural context of each component. A hero banner and an exit-intent overlay have different performance requirements, different ownership models, and different tolerance for loading states. They should probably use different patterns.

The best practices guide and the guide to using APIs and script together cover the practical details of hybrid implementations, including cookie sharing, identity resolution, and keeping your reporting consistent across patterns.

  1. One interesting difference is regarding the IP address in the request payload, used for geolocation. In client-side calls, it is often easier to omit it as Dynamic Yield’s servers can see the browser’s IP address, while it’s not easy for the browser to know its own in order to provide it.