A critical vulnerability was discovered in React Server Components (Next.js). Our systems remain protected but we advise to update packages to newest version. Learn More

dada
Jun 26, 2025
  186
(0 votes)

Make Global Assets Site- and Language-Aware at Indexing Time

I had a support case the other day with a question around search on global assets on a multisite. This is the result of that investigation.
This code is provided as-is but please let me know if you have any feedback.

In Episerver (Optimizely) CMS, global assets—such as images, documents, and blocks stored in the Global Assets folder—are not tied to a specific site or language. 
Yet, the content that references typically is.

You might have noticed a common issue:
When implementing a search on Site A, assets referenced only by content on Site B still appear in the results and vice versa. That’s maybe not what we want .

Let’s say you have:

  • Assets used exclusively on Site A, or

  • Assets shared between Site A and Site B.

When a user searches on Site A, you want results that are relevant only to that site—including global assets referenced by that site’s content. Likewise, if an asset is shared between both sites, it should appear in both result sets.

To support this, your indexing process must make global assets aware of which sites and languages reference them.

This Is the Way

If you're using UnifiedSearch, this filtering on Site ID and language typically works out of the box via:

* FilterOnCurrentSite() / FilterOnSite()
* FilterForVisitor() / PublishedInLanguage()

And for typed searches, you can easily apply these filters yourself.

Update the Initialization Module

To get this working for global assets, we override the default indexing behavior for SiteId() and PublishedInLanguage() by excluding the built-in fields and replacing them with our own logic.

SearchClient.Instance.Conventions.ForInstancesOf<IContentMedia>().ExcludeField(x => x.SiteId());
SearchClient.Instance.Conventions.ForInstancesOf<IContentMedia>().IncludeField(x => x.SiteId(true));
SearchClient.Instance.Conventions.ForInstancesOf<IContentMedia>().ExcludeField(x => x.PublishedInLanguage());
SearchClient.Instance.Conventions.ForInstancesOf<IContentMedia>().IncludeField(x => x.PublishedInLanguage(true));

Extension Methods

We define custom logic in a static class to determine which sites and languages reference a given global asset. 

using EPiServer.Find.Cms;
using EPiServer.Framework.Cache;
using EPiServer.ServiceLocation;
using EPiServer.Web;
using EPiServer.Find.Helpers;

public static class GlobalAssetsExtensions
{
    private static readonly Lazy<ISiteDefinitionResolver> _siteDefinitionResolver =
        new(() => ServiceLocator.Current.GetInstance<ISiteDefinitionResolver>());

    private static readonly Lazy<IContentLoader> _contentLoader =
        new(() => ServiceLocator.Current.GetInstance<IContentLoader>());

    private static readonly Lazy<IContentRepository> _contentRepository =
        new(() => ServiceLocator.Current.GetInstance<IContentRepository>());

    private static readonly Lazy<IContentVersionRepository> _versionContentRepository =
        new(() => ServiceLocator.Current.GetInstance<IContentVersionRepository>());

    private static readonly Lazy<IObjectInstanceCache> _objectCache =
        new(() => ServiceLocator.Current.GetInstance<IObjectInstanceCache>());

    private static readonly Lazy<IContentCacheKeyCreator> _contentCacheKeyCreator =
        new(() => ServiceLocator.Current.GetInstance<IContentCacheKeyCreator>());

    public static IEnumerable<string> SiteId(this IContentMedia content, bool foobar)
    {        
        content.ValidateNotNullArgument(nameof(content));

        var siteDefinitionResolver = _siteDefinitionResolver.Value;
        var contentLoader = _contentLoader.Value;
        var contentRepository = _contentRepository.Value;
        var objectCache = _objectCache.Value;
        var contentCacheKeyCreator = _contentCacheKeyCreator.Value;

        if (siteDefinitionResolver.GetByContent(content.ContentLink, false) == null)
        {
            if (ContentReference.IsNullOrEmpty(content.ParentLink) ||
                !contentLoader.TryGet<IContent>(content.ParentLink, out _))
            {
                return Enumerable.Empty<string>();
            }

            if (contentLoader.GetAncestors(content.ContentLink)
                             .Any(x => x.ContentLink == ContentReference.GlobalBlockFolder))
            {
                var cacheKey = $"SiteIds:{content.ContentLink.ID}";
                var cachedSiteIds = objectCache.Get(cacheKey) as IEnumerable<string>;
                if (cachedSiteIds != null)
                {
                    return cachedSiteIds;
                }

                var contentRefs = new HashSet<ContentReference>();

                foreach (var link in contentRepository.GetReferencesToContent(content.ContentLink, false))
                {
                    if (!ContentReference.IsNullOrEmpty(link.OwnerID))
                    {
                        contentRefs.Add(link.OwnerID);
                    }
                }

                var siteIds = new HashSet<string>();
                foreach (var contentRef in contentRefs)
                {
                    var site = siteDefinitionResolver.GetByContent(contentRef, false);
                    if (site != null)
                    {
                        siteIds.Add(site.Id.ToString());
                    }
                }

                objectCache.Insert(cacheKey, siteIds, new CacheEvictionPolicy(
                    TimeSpan.FromMinutes(120), CacheTimeoutType.Sliding,
                    [contentCacheKeyCreator.VersionKey]));

                return siteIds;
            }
        }

        return [content.SiteId()];
    }

    public static Dictionary<string, LanguagePublicationStatus> PublishedInLanguage(this IContentMedia content, bool foobar)
    {
        content.ValidateNotNullArgument(nameof(content));

        if (ContentReference.IsNullOrEmpty(content?.ContentLink))
        {
            return null;
        }

        var contentLoader = _contentLoader.Value;
        var contentRepository = _contentRepository.Value;
        var versionRepository = _versionContentRepository.Value;
        var objectCache = _objectCache.Value;
        var contentCacheKeyCreator = _contentCacheKeyCreator.Value;

        if (contentLoader.GetAncestors(content.ContentLink)
                         .Any(x => x.ContentLink == ContentReference.GlobalBlockFolder))
        {
            // Should we need to check whether entire ancestor chain are resolvable?

            var cacheKey = $"Languages:{content.ContentLink.ID}";
            var cachedLanguages = objectCache.Get(cacheKey) as Dictionary<string, LanguagePublicationStatus>;
            if (cachedLanguages != null)
            {
                return cachedLanguages;
            }

            var contentRefs = new HashSet<ContentReference>();

            foreach (var link in contentRepository.GetReferencesToContent(content.ContentLink, false))
            {
                if (!ContentReference.IsNullOrEmpty(link.OwnerID))
                {
                    contentRefs.Add(link.OwnerID);
                }
            }

            var languages = new Dictionary<string, LanguagePublicationStatus>(StringComparer.OrdinalIgnoreCase);

            foreach (var contentRef in contentRefs)
            {
                var publishedLanguages = versionRepository
                    .ListPublished(contentRef)
                    .Select(x => x.LanguageBranch);
                
                foreach (var lang in publishedLanguages)
                {
                    if (contentLoader.TryGet<IContent>(contentRef, new LanguageSelector(lang), out var contentInLang))
                    {
                        var langStatus = contentInLang.PublishedInLanguage();
                        if (langStatus != null)
                        {
                            foreach (var kvp in langStatus)
                            {
                                languages.TryAdd(kvp.Key, kvp.Value);
                            }
                        }
                    }
                }
            }

            objectCache.Insert(cacheKey, languages, new CacheEvictionPolicy(
                TimeSpan.FromMinutes(120), CacheTimeoutType.Sliding,
                [contentCacheKeyCreator.VersionKey]));

            return languages;
        }

        return content.PublishedInLanguage();
    }
}

What It Looks Like in the Index

After reindexing a global asset referenced across different sites and languages, it will now include enriched metadata, like this

"PublishedInLanguage": { "sv": { "StartPublish$$date": "2017-02-22T11:24:00Z" },
 "fi": { "StartPublish$$date": "2025-06-18T11:03:00Z" },
 "en": { "StartPublish$$date": "2012-10-04T11:53:00Z" },
 "en-GB": { "StartPublish$$date": "2025-05-21T06:24:00Z" } }, 
"SiteId": [ "site-a-guid", "site-b-guid" ]
Jun 26, 2025

Comments

Please login to comment.
Latest blogs
A day in the life of an Optimizely OMVP: Learning Optimizely Just Got Easier: Introducing the Optimizely Learning Centre

On the back of my last post about the Opti Graph Learning Centre, I am now happy to announce a revamped interactive learning platform that makes...

Graham Carr | Jan 31, 2026

Scheduled job for deleting content types and all related content

In my previous blog post which was about getting an overview of your sites content https://world.optimizely.com/blogs/Per-Nergard/Dates/2026/1/sche...

Per Nergård (MVP) | Jan 30, 2026

Working With Applications in Optimizely CMS 13

💡 Note:  The following content has been written based on Optimizely CMS 13 Preview 2 and may not accurately reflect the final release version. As...

Mark Stott | Jan 30, 2026

Experimentation at Speed Using Optimizely Opal and Web Experimentation

If you are working in experimentation, you will know that speed matters. The quicker you can go from idea to implementation, the faster you can...

Minesh Shah (Netcel) | Jan 30, 2026