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:

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

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.

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.

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 Mustache templates with the class idio-recommendations. (Actually, it looks for any script elements with that class.)

  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' ? 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 when window.idio.r0 is accessed (instead of returning what window.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()
  }, [])
}