How to render Optimizely Content Recommendations using React

Published on in Episerver, JavaScript and React

Last updated on

As known as Episerver/Idio Content Recommendations. The usual way of using a Mustache template can be circumvented by using a JavaScript proxy.

Table of contents

What is Optimizely Content Recommendations?

Episerver's marketing page describes Web Content Recommendations like so:

Serve each visitor the most relevant content automatically. AI-powered recommendations act upon the unique interests of each visitor in real-time to help you deliver personalization with minimal manual effort.

Optimizely's developer docs describe Content Recommendations maybe slightly more clearly:

Optimizely Content Recommendations automatically generates a personalized content feed for each visitor based on the individual's site activity.

Content Recommendations uses Natural Language Processing (NLP) to understand the meaning of each piece of content at a granular level and builds a real-time interest profile for each visitor based on their interactions with the NLP-generated topics. Content Recommendations uses this information to recommend articles, blog posts, or other specified content sections that are most relevant to the visitor's interest profile.

TL;DR: Personalized content recommendations based on visitor tracking and natural language processing of the content pages.

Optimizely vs Episerver vs Idio

Optimizely Content Recommendations has been previously called Episerver Content Recommendations, and before that Idio Content Recommendations. Some context:

Usual implementation: Mustache template

On pages where you want to see intelligent content recommendations:

  1. Add a Mustache template with the class .idio-recommendations. Example:

      <script
    class="idio-recommendations"
    data-api-key="..."
    type="text/x-mustache"
    >

    <div class="recommendation">
    {{#content}}
    <h2>
    <a href="{{link_url}}">{{title}}</a>
    </h2>
    <img alt="" src="{{main_image_url}}" />
    <p>{{abstract}}</p>
    <p>
    Published on <span class="date">{{published}}</span>
    </p>
    {{/content}}
    </div>
    </script>
  2. Load ip.js script ("Idio Personalization").

When the page is loaded, the ip.js script will look for the Mustache template, and if found, load content recommendations via an Ajax request and render the Mustache template with the loaded data.

Quite straightforward. If this works for you, great!

Problems when using with React

At work, our front-end is built using React. Rendering a Mustache template to the page is not a problem. Some other basic things are however difficult:

  • How to format dates in the Mustache template? They are rendered in the ISO 8601 format by default and I haven't found a way to customize that.
  • How to handle links in the Mustache template so that the links are handled like SPA (single page app) links?

I suspect that other basic things would also be difficult.

Naive and hacky React implementation

Content Recommendations offers a way to run your own logic after the Mustache template has been populated with data.

After ip.js has loaded the data via Ajax and rendered the Mustache template, it will look for a window._ipc object (apparently stands for "Idio Personalization Config") and call its complete function (if it exists) with two arguments: a reference to the DOM element containing the recommendations, and a status code of the Ajax response.

So it should be possible to do these steps after the Mustache template has been rendered:

  • Mutate the dates inside the DOM element to properly format them.
  • Attach click event listeners to the links inside the DOM element.

Something like this (quick and dirty code):

function ContentRecommendations() {
useEffect(() => {
let links = []

window._ipc = {
complete(element, statusCode) {
if (!element || statusCode !== 200) return

element.querySelectorAll('.date').forEach((date) => {
date.textContent = new Date(date.textContent).toLocaleString('en')
})

links = element.querySelectorAll('a')
links.forEach((link) => {
link.addEventListener('click', handleLinkClick)
})
},
}

return () => {
links.forEach((link) => {
link.removeEventListener('click', handleLinkClick)
})
}
}, [])

return (
<script
className="idio-recommendations"
data-api-key="..."
type="text/x-mustache"
>
...
</script>
)
}

Notice that I have to remove the links' click event listeners in the useEffect's cleanup function to avoid memory leaks.

Is this good? Nah, it's very hacky. I don't like to see this kind of code in a React project.

If there are more things that you have to do like this – things that you can't do with just the Mustache template – the code becomes even uglier.

Better (albeit still hacky) React implementation

It would be nice if we could do the Ajax requests ourselves, but that's not possible. Plus in the end I think it's best to leave the request logic to ip.js.

It would be even better if we could get the Ajax response's data directly instead of letting ip.js use it to render the Mustache template. But that's not possible... unless we use a certain hack. ðŸĪŠ

The flow of things

I did some digging, and the flow goes like this:

  1. After the page has loaded, ip.js does some initializations like this:
    window.idio = window.idio || {}
    window.idio.render = function () { /* ... */ }
    window.idio.load = function () { /* ... */ }
    // ...
    Notice how ip.js doesn't override the window.idio object if it already exists.
  2. ip.js looks for a Mustache template with the class .idio-recommendations.
  3. If a template is found, ip.js does an Ajax request to get content recommendations for the user.
  4. The Ajax response contains JavaScript code like this:
    window.idio.r0(
    {
    total_hits: 1234,
    content: [
    { /* ... */ },
    { /* ... */ },
    ],
    // ...
    },
    200
    )
    I.e. the returned code calls window.idio.r0 with the content recommendations data and a status code of the Ajax response.
  5. window.idio.r0 renders the Mustache template with the data.

If the page contains more than one Mustache template, the first Ajax response calls window.idio.r0, the second one calls window.idio.r1, and so on. So the "r" is apparently "render" or "result," and the number is a 0-based index.

The hack: proxying window.idio

So, everything is fine to us except the last step. If only we could do something else than let ip.js render the Mustache template when window.idio.r0 is called...

Well, that's what JavaScript proxies are for! We can use one to make window.idio.r0 return something else when it's accessed.

As we saw earlier, ip.js doesn't override the window.idio object if it already exists. So we can define it ourselves before loading ip.js:

window.idio = new Proxy(
{},
{
get(target, prop) {
return prop === 'r0' ? myRenderFunction : target[prop]

// Alternatively, if the page contains more than one Mustache template:
return /^r\d+$/.test(prop) ? myRenderFunction : target[prop]
},
}
)

function myRenderFunction(data, statusCode) {
// Create a React component based on `data`
// and render it using `ReactDOM.render()`
}

Here I'm setting window.idio to a proxy object which returns myRenderFunction when window.idio.r0 is accessed (instead of returning what window.idio.r0 actually is), and otherwise makes window.idio work normally.

In other words, I can do whatever I like in myRenderFunction, which means that I can use React to render the content recommendations.

Heh, this is extremely hacky and fragile, but so is the Mustache + React combo. At least now I don't have to have a mix of Mustache, React and procedural DOM mutations.

Goodbye Mustache!

Sample code

I'll leave it as an exercise for you to clean up and properly comment the code. 🙂

/** Loads content recommendations. */
function ContentRecommendationsLoader() {
const containerRef = useRef()

useIdioProxy(containerRef)
useIdioPersonalizationScript()

return (
<>
<script
className="idio-recommendations"
data-api-key="..."
type="text/x-mustache"
/>

<div ref={containerRef} />
</>
)
}

/** Renders the content recommendations. */
function ContentRecommendations({ data }) {
const formatDate = (date) =>
new Date(date).toLocaleString('en', { dateStyle: 'medium' })

return data.content.map((recommendation) => (
<div className="recommendation" key={recommendation.id}>
<h2>
{/* Look ma, can use router links! */}
<RouterLink href={recommendation.link_url}>
{recommendation.title}
</RouterLink>
</h2>
<img alt="" src={recommendation.main_image_url} />
<p>{recommendation.abstract}</p>
<p>
{/* Look ma, date formatting! */}
Published on
{formatDate(recommendation.published)}
</p>
</div>
))
}

/**
* Create a proxy object for `window.idio`
* so that `window.idio.r0` returns our own render function.
* Must be run before `ip.js` is loaded.
*
* @param {React.RefObject<HTMLElement>} containerRef
* HTML element ref where to render the content recommendations.
*/

function useIdioProxy(containerRef) {
const renderContentRecommendations = useCallback(
(data, statusCode) => {
if (statusCode !== 200) {
// Show error or do logging or whatever
return
}

ReactDOM.render(
<ContentRecommendations data={data} />,
containerRef.current
)
},
[containerRef]
)

useEffect(() => {
window.idio = new Proxy(
{},
{
get(target, property) {
return property === 'r0'
? renderContentRecommendations
: target[property]
},
}
)

const container = containerRef.current
return () => ReactDOM.unmountComponentAtNode(container)
}, [containerRef, renderContentRecommendations])
}

/**
* Load `ip.js` ("Idio Personalization")
* which loads Optimizely Content Recommendations.
*/

function useIdioPersonalizationScript() {
useEffect(() => {
const script = document.createElement('script')
script.src = 'https://.../ip.js'

document
.querySelector('script')
.insertAdjacentElement('beforebegin', script)

return () => script.remove()
}, [])
}