How to render Optimizely Content Recommendations using React
Published on in Episerver, JavaScript and React
Last updated on
Also 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 have been previously called Episerver Content Recommendations, and before that Idio Content Recommendations. Some context:
- Episerver acquired a company called Idio in November 2019.
- Episerver acquired a company called Optimizely in October 2020 and now Episerver is rebranding itself as Optimizely.
Usual implementation: Mustache template
On pages where you want to see intelligent content recommendations:
-
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>
-
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
Rendering a Mustache template to the page using React 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 (not recommended)
I don't recommend this approach. Check the next section for a better approach. I'm documenting this here because this is what I tried first.
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.
By utilizing the window._ipc
object,
it should be possible to
do these steps after the Mustache template has been rendered:
- Format dates inside the DOM element by mutating them.
- Attach click event listeners to the links inside the DOM element for SPA-like navigation.
Something like this (quick and dirty code):
function ContentRecommendations() {
useIdioPersonalizationConfig()
return (
<script
className="idio-recommendations"
data-api-key="..."
type="text/x-mustache"
>
...
</script>
)
}
function useIdioPersonalizationConfig() {
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)
})
}
}, [])
}
Notice how 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 (recommended)
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:
-
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 thewindow.idio
object if it already exists. -
ip.js
looks for Mustache templates with the classidio-recommendations
. (Actually, it looks for anyscript
elements with that class.) -
If a template is found,
ip.js
does an Ajax request to get content recommendations for the user. -
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. -
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' ? handleAjaxResponse : target[prop]
// Alternatively, if the page can contain more than one Mustache template:
return /^r\d+$/.test(prop.toString()) ? handleAjaxResponse : target[prop]
// The `toString()` is needed because `prop` can be a Symbol,
// and the `test()` method would otherwise throw:
// "Cannot convert a Symbol value to a string"
},
}
)
function handleAjaxResponse(data, statusCode) {
// Do whatever with the data ðĪ
}
Here I'm setting window.idio
to a proxy object which:
- Returns my own
handleAjaxResponse
function whenwindow.idio.r0
is accessed (instead of returning whatwindow.idio.r0
actually is). - Otherwise makes
window.idio
work normally.
In other words,
I can do whatever I like in handleAjaxResponse
.
Check the next section for an example what to do with the data.
Heh, this is 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. ð
function ContentRecommendations() {
const recommendations = useContentRecommendations()
return (
<>
{/*
Content recommendations are loaded
only if a Mustache template with this class is found on the page.
*/}
<script
className="idio-recommendations"
data-api-key="..."
data-rpp="5" // Results per page
type="text/x-mustache"
/>
{recommendations.length > 0 && (
<>
<h2>Recommended content</h2>
<ContentRecommendationsList recommendations={recommendations} />
</>
)}
</>
)
}
function ContentRecommendationsList({ recommendations }) {
const formatDate = (date) =>
new Date(date).toLocaleString('en', { dateStyle: 'medium' })
return recommendations.map((recommendation) => (
<div key={recommendation.id}>
<h3>
{/* Look ma, can use router links! */}
<RouterLink href={recommendation.link_url}>
{recommendation.title}
</RouterLink>
</h3>
<img alt="" src={recommendation.main_image_url} />
<p>{recommendation.abstract}</p>
<p>
{/* Look ma, date formatting! */}
Published on {formatDate(recommendation.published)}
</p>
</div>
))
}
/**
* Get content recommendations loaded by `ip.js` ("Idio Personalization").
*
* Hacky! Read on for details.
*
* What `ip.js` does:
*
* 1. Look for a Mustache template with the class `idio-recommendations`.
* 2. If found, load content recommendations data via Ajax.
* 3. Call `window.idio.r0(data, statusCode)`
* with the Ajax response data and status code.
*
* Normally `window.idio.r0` compiles the Mustache template,
* but `useIdioProxy` intercepts calls to `window.idio.r0`
* so that we can handle the data ourselves.
*
* More detailed description and original code at
* https://mtsknn.fi/blog/how-to-render-optimizely-content-recommendations-using-react/
*
* @returns {object[]}
*/
function useContentRecommendations() {
const [recommendations, setRecommendations] = useState([])
useIdioProxy(setRecommendations)
useIdioPersonalizationScript()
return recommendations
}
/**
* Create a proxy object for `window.idio`
* to intercept calls to `window.idio.r0`.
*
* Must be run before loading `ip.js`
* to ensure that the proxy is in place
* when loading the content recommendations data.
*
* `ip.js` sets `window.idio = window.idio || {}`,
* so our proxy isn't overridden.
*
* @param {React.Dispatch<React.SetStateAction<object[]>>} setRecommendations
* React state setter.
*/
function useIdioProxy(setRecommendations) {
const handleAjaxResponse = useCallback(
(data, statusCode) => {
if (statusCode !== 200) {
// Show error or do logging or whatever
return
}
setRecommendations(data.content)
},
[setRecommendations]
)
useEffect(() => {
window.idio = new Proxy(
{},
{
get(target, property) {
return property === 'r0' ? handleAjaxResponse : target[property]
},
}
)
return () => delete window.idio
}, [handleAjaxResponse])
}
/**
* Load `ip.js` ("Idio Personalization")
* which loads Optimizely Content Recommendations.
*/
function useIdioPersonalizationScript() {
useEffect(() => {
const script = document.createElement('script')
script.async = true
script.src = 'https://.../ip.js'
document
.querySelector('script')
.insertAdjacentElement('beforebegin', script)
return () => script.remove()
}, [])
}