Take the community feedback survey now.

dada
Jun 26, 2025
  552
(1 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

Sameul
Sameul Jul 22, 2025 10:54 AM

This is a fantastic solution to a problem many multisite editors face—thanks for breaking it down so clearly! The custom logic for enriching global asset metadata with site and language context is incredibly helpful. I’m definitely looking into implementing this on TalkSocially.com, where we manage multilingual content across different sections. Really appreciate you sharing this in such detail—great work!

dada
dada Aug 11, 2025 07:02 AM

Thanks for the kind words @Sameul! Glad to hear it will come to use.

Sep 10, 2025 05:27 AM

Really impressed by how this Optimizely guide tackles the challenge of global assets returning across sites or languages—by enriching indexing with site and language context, it ensures relevance where it matters. The custom override of default indexing behavior and clever use of extension methods truly smarten up search results.
Thanks for sharing this deep dive—it’s exactly the kind of practical insight we love at AimGrip . Looking forward to exploring how it can elevate multisite content discovery on our platform!

Please login to comment.
Latest blogs
Quiet Performance Wins: Scheduled Job for SQL Index Maintenance in Optimizely

As Optimizely CMS projects grow, it’s not uncommon to introduce custom tables—whether for integrations, caching, or specialized business logic. But...

Stanisław Szołkowski | Oct 8, 2025 |

Image Generation with Gemini 2.5 Flash

Gemini 2.5 Flash Image, nicknamed Nano Banana, is Google DeepMind’s newest image generation & editing model. It blends text‑to‑image, multi‑image...

Luc Gosso (MVP) | Oct 8, 2025 |

Automated Page Audit for Large Content Sites in Optimizely

Large content sites often face significant challenges in maintaining visibility and control over thousands of pages. Content managers struggle to...

Sanjay Kumar | Oct 6, 2025

Optimizely CMS Roadmap – AI, automation and the future of digital experiences

A summary of the roadmap for Optimizely CMS from the Opticon conference on September 30, 2025.

Tomas Hensrud Gulla | Oct 6, 2025 |