4 computers each connected to the Cloud
All blogs
Optimizely

Beyond the widget - making Optimizely Content Recommendations work for you

Tom Robinson
Tom RobinsonTechnical Analyst at MSQ DX

If you've worked with Optimizely Content Recommendations (formerly IDIO), you'll know the default implementation path: drop in the JavaScript Mustache widget, configure some basic settings, and let the platform handle the rendering. For many projects, that works fine. But when you're building with a modern frontend stack and a solid design system, that approach can feel like forcing a square peg into a round hole.

What’s the big deal?

Widget-based implementations don't always fit. The out-of-the-box Optimizely Content Recommendations widget is designed for broad compatibility. It injects its own markup, applies its own styling, and manages its own rendering lifecycle. This creates friction when you're working with React, Next.js, or any component-based architecture where you want full control over:

  • Rendering lifecycle - you need recommendations to load as part of your component tree, not as an external script that manipulates the DOM independently
  • Design system integration - your cards, typography, and spacing should come from your existing design system, not hacked in with separately managed styles
  • Data transformation - you might want to filter, sort, or reshape the recommendation data before displaying it
  • Error handling and loading states - your app probably has established patterns for these; the widget has its own opinions

The widget approach also complicates hydration in SSR/SSG contexts and makes it harder to compose recommendations with other data sources.

Cool, so how do we fix it?

Talk direct to the API. Optimizely Content Recommendations exposes a JSONP endpoint that returns personalised content based on the visitor's behaviour profile. By calling this directly, you get raw recommendation data that you can render however you like.

The core endpoint follows this pattern:

https://api.emea01.idio.episerver.net/1.0/users/idio_visitor_id:{visitorId}/content
  ?key={yourKey}

The visitorId comes from a cookie (iv) that the Optimizely tracking script sets. You'll still need their tracking in place to build visitor profiles, but the rendering becomes entirely yours.

Practical implementation in React

The API uses JSONP, which means you need to construct a URL with a callback function name and inject it as a script tag. Here's the general approach:

  1. Read the visitor ID from the iv cookie (set by Optimizely's tracking script)
  2. Generate a unique callback name for each component instance to avoid collisions
  3. Register a global callback function that will receive the response
  4. Inject a script tag with the API URL to trigger the request
  5. Clean up on unmount by removing both the callback and the script

The URL structure looks like this:

https://api.emea01.idio.episerver.net/1.0/users/idio_visitor_id:{visitorId}/content
  ?include_topics
  &callback={yourCallbackFunctionName}
  &key={yourApiKey}
  &session[]={encodedCurrentPageUrl}

One gotcha: the iv cookie might not exist immediately on component mount if the tracking script hasn't executed yet. A common pattern is to check for it on mount, then recheck after a short delay (200ms or so) to handle the race condition.

You can also pass additional query parameters to control the response:

  • include_topics - include the generated Topics in the response
  • rpp - results per page (default= 10)
  • page - which page to return, offset by rpp (default= 1)
  • filter - a Lucene query to restrict results (more on this below!)
  • section - only include content from a section (sections are found in the Content Recs platform)
  • fallback - section to use if the visitor doesn't have a personalisation model yet
  • fallback-filter - Lucene query for fallback results

Important to note that section is ignored if a filter is also present. The same applies for fallback and fallback-filter.

Working with the API response

The response gives you everything you need to build rich recommendation cards. Each content item includes at least:

{
  "id": "123456",
  "title": "Article title here",
  "abstract": "The article summary or description",
  "published": "2026-01-14 12:45:22",
  "original_url": "https://example.com/article",
  "link_url": "https://example.com/article?tracking_params",
  "main_image_url": "//i.idio.co/image-id",
  "source": {
    "id": "3",
    "title": "www.example.com"
  },
  "author": null,
  "topics": [
    { "id": "72", "title": "News Articles" },
    { "id": "42", "title": "United Kingdom" }
  ]
}

The default fields cover most use cases: title, abstract, link_url, main_image_url, source, author, and topics. Most of the returned data is content that has been scraped from your pages initially, but the Content Recs platform enhances the information in abstract and the topics array. Abstract is a generated summary of the scraped content, and Topics gives you the generated content graph topics associated with the article.

Note that main_image_url comes back as a protocol-relative URL (starting with //), so you'll need to prepend https: when using it in image components.

The response may also include a metadata object containing scraped meta tags and any custom properties. The structure varies depending on your Content Recs configuration, but commonly includes metadata.language, og properties, and custom tags under metadata.tags.idio.

Another bonus of this approach, you’re in control of content fallbacks for if the API doesn’t return some values. Brilliant for making sure the cards always render with an image!

Filtering with Lucene queries

One of the more powerful (and underused) features of the direct API approach is the ability to filter results using Lucene query syntax via the filter parameter. This lets you exclude specific content, restrict by topic, or combine multiple conditions.

When using filters, the endpoint URL changes slightly — you need to append /_query to the path and add &_method=post to the query string:

https://api.emea01.idio.episerver.net/1.0/users/idio_visitor_id:{visitorId}/content/
  _query
  ?callback={yourCallbackFunctionName}
  &key={yourApiKey}
  &filter={yourLuceneQuery}
  &_method=post

A key gotcha: the Lucene index field names don't always match the JSON response structure. For example, the response shows topics[].id, but you query it as topic:75 — not topics.id:75.

Here are some practical examples:

  • Exclude specific content IDs (useful for "don't recommend the xyz article"):
    • filter=NOT id:12345
  • Filter by topic:
    • filter=topic:72
  • Filter by language:
    • filter=metadata.language:en
  • Combine multiple conditions:
    • filter=metadata.language:en AND topic:72 AND NOT id:12345
  • Multi-word values need quotes (URL-encoded):
    • filter=metadata.tags.idio.custom-categorisation-property:%22News%22
  • Exclude the current page by URL (useful for "don't recommend this article on itself"):
    • filter=NOT original_url:*your-article-slug*
    • The original_url field supports wildcard matching with *, so you can match on URL patterns rather than needing the exact full URL. This is handy when you want to pass a slug or partial path rather than constructing the complete URL.

The boolean operators AND, OR, and NOT all work as expected. You can chain multiple exclusions too. Handy if you're on a hub page and want to exclude several featured articles from the recommendations.

When building your query string, remember to URL-encode properly: spaces become %20, colons become %3A, and quotes become %22. Most HTTP client libraries handle this for you, but it's worth knowing if you're debugging in the browser.

Edge cases to handle

First-time visitors won't have an iv cookie until the tracking script creates one, so consider using a skeleton state or the data-fallback parameter to specify a section for non-personalised content. The API might also return empty arrays for new visitors, so plan your fallback UI accordingly.

When to use this approach

Direct API integration makes sense when you're invested in a component-based frontend and want recommendations to feel native. It's more work than dropping in a widget, but you gain complete control over the user experience and avoid styling conflicts.

You could even take this approach to the next level, and use the API to build a fully personalised listing page, taking advantage of the query parameters we explained above to implement features such as Load More or filtering.

If you're building a marketing site where development velocity matters more than pixel-perfect integration, the standard widget is probably fine. But for product interfaces, design system consistency, or complex data orchestration, going direct to the API is worth it.

Need help building the perfect content recommendations implementation? Get in touch with MSQ DX to see how we can help.