dada
Optimizely
Jun 12, 2025
visibility 1778
star star star star star
(6 votes)

Create a multi-site aware custom search provider using Search & Navigation

In a multisite setup using Optimizely CMS, searching for pages can be confusing. The default CMS search regardless of search provider does not clearly indicate which site a given result belongs to—especially when you have identical page names across multiple sites.

This post shows how you can create a custom search provider, using Search & Navigation, to make your search results site-aware.

Solution

By creating a custom search provider that appends site context (like the start page or site name) to each result, you can make the search output clearer and easier to work with.

In the example below, we add the start page name as a suffix to the page title in the results. This makes it obvious which site a page belongs to. Pages that are not under any site start page appear without a suffix and show up at the top of the results, since they are not associated with any specific site. 

You can easily adapt this to use the site name instead of the start page name if you prefer.

This code is provided as-is, without guarantees or support. However, I’d be happy to hear your feedback or thoughts in the comments.

Implement it

  1. Add new class MultiSitePageSearchProvider.cs to your solution. Attached further down.

  2. In Admin mode > Settings -> Search Configuration, enable the new Find pages (Multisite) provider and disable the default Find pages provider.

  3. Try a few searches in edit mode and confirm that results now show which site they belong to.

Example results before

... and after

 

MultiSitePageSearchProvider.cs

using EPiServer.Find;
using EPiServer.Find.Framework;
using EPiServer.Find.UI.Models;
using EPiServer.Find.UnifiedSearch;
using EPiServer.Framework;
using EPiServer.Framework.Localization;
using EPiServer.Globalization;
using EPiServer.ServiceLocation;
using EPiServer.Shell;
using EPiServer.Shell.Search;
using EPiServer.Web;
using EPiServer.Web.Routing;
using Newtonsoft.Json.Linq;


namespace EPiServer.Find.Cms.SearchProviders
{

    [SearchProvider]
    public class MultiSitePageSearchProvider : EnterprisePageSearchProvider
    {

        private readonly UIDescriptorRegistry _uiDescriptorRegistry;        
        private readonly ISiteDefinitionRepository _siteDefinitionRepository;
        private readonly IContentLoader _contentLoader;
        private readonly IClient _searchClient;
        private readonly ILogger<MultiSitePageSearchProvider> _logger;

        private const string AllowedTypes = "allowedTypes";
        private const string RestrictedTypes = "restrictedTypes";

        public override string Area => FindContentSearchProviderConstants.PageArea;
        public override string Category => "Find pages (Multisite)";

        public MultiSitePageSearchProvider(
            LocalizationService localizationService,
            ISiteDefinitionResolver siteDefinitionResolver,
            IContentTypeRepository<PageType> contentTypeRepository,
            UIDescriptorRegistry uiDescriptorRegistry,
            EditUrlResolver editUrlResolver,
            ServiceAccessor<SiteDefinition> siteDefinitionAccessor,
            IContentLanguageAccessor contentLanguageAccessor,
            IUrlResolver urlResolver,
            ITemplateResolver templateResolver,
            IContentRepository contentRepository,
            ISiteDefinitionRepository siteDefinitionRepository,
            IContentLoader contentLoader,
            IClient searchClient,
            ILogger<MultiSitePageSearchProvider> logger)
            : base(localizationService, siteDefinitionResolver, contentTypeRepository, uiDescriptorRegistry, editUrlResolver, siteDefinitionAccessor, contentLanguageAccessor, urlResolver, templateResolver, contentRepository)
        {
            _uiDescriptorRegistry = uiDescriptorRegistry;
            _siteDefinitionRepository = siteDefinitionRepository;
            _contentLoader = contentLoader;
            _searchClient = searchClient;
            _logger = logger;
        }

        public override IEnumerable<SearchResult> Search(Query query)
        {
            Validator.ThrowIfNull("SearchProviderFactory.Instance.AccessFilter", FilterFactory.Instance.ContentAccessFilter);
            Validator.ThrowIfNull("SearchProviderFactory.Instance.CultureFilter", FilterFactory.Instance.CultureFilter);
            Validator.ThrowIfNull("SearchProviderFactory.Instance.RootsFilter", FilterFactory.Instance.RootsFilter);

            query.MaxResults = 20;

            ITypeSearch<IContentData> searchQuery = GetFieldQuery(query.SearchQuery, query.MaxResults)
                .Filter(x => x.MatchTypeHierarchy(typeof(IContentData)));

            var allowedTypes = GetContentTypesFromQuery(AllowedTypes, query);
            var restrictedTypes = GetContentTypesFromQuery(RestrictedTypes, query);

            FilterContext filterContext = FilterContext.Create<IContentData, ContentType>(query);
            searchQuery = FilterFactory.Instance.AllowedTypesFilter(searchQuery, filterContext, allowedTypes);
            searchQuery = FilterFactory.Instance.RestrictedTypesFilter(searchQuery, filterContext, restrictedTypes);
            searchQuery = FilterFactory.Instance.ContentAccessFilter(searchQuery, filterContext);
            searchQuery = FilterFactory.Instance.CultureFilter(searchQuery, filterContext);
            searchQuery = FilterFactory.Instance.RootsFilter(searchQuery, filterContext);

            var contentLinksWithLanguage = Enumerable.Empty<ContentInLanguageReference>();

            try
            {
                contentLinksWithLanguage = searchQuery
                    .Select(x =>
                        new ContentInLanguageReference(
                            new ContentReference(((IContent)x).ContentLink.ID,
                                                 ((IContent)x).ContentLink.ProviderName),
                            ((ILocalizable)x).Language.Name))
                    .StaticallyCacheFor(TimeSpan.FromMinutes(1), UnifiedWeightsCache.ChangeToken)
                    .GetResultAsync()
                    .GetAwaiter()
                    .GetResult();
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error during CMS page search query execution: {Message}", ex.Message);
                return Enumerable.Empty<SearchResult>();
            }

            PageData content = null;

            // Check if there are multiple sites with actual start pages
            var hasMultipleResolvableSites = _siteDefinitionRepository.List()
                                                                    .Where(site => !ContentReference.IsNullOrEmpty(site.StartPage) &&
                                                                                   _contentLoader.TryGet<IContent>(site.StartPage, out _))
                                                                    .Skip(1)
                                                                    .Any();

            return contentLinksWithLanguage
                .Where(
                    searchResult =>
                    _contentLoader.TryGet<PageData>(searchResult.ContentLink,
                                                       !String.IsNullOrEmpty(searchResult.Language)
                                                           ? new LanguageSelector(searchResult.Language)
                                                           : LanguageSelector.AutoDetect(true), out content))
                .Select(item =>
                {
                    var result = CreateSearchResult(content);

                    if (hasMultipleResolvableSites && Guid.TryParse(content.SiteId(), out var siteId))
                    {                        
                        var site = _siteDefinitionRepository.List().FirstOrDefault(s => s.Id == siteId);
                        if (site != null)
                        {
                            var startPageName = _contentLoader.Get<IContent>(site.StartPage)?.Name;

                            if (!string.IsNullOrEmpty(startPageName))
                            {
                                result.Metadata.Add("SortKey", startPageName);
                                result.Title = $"{startPageName} \\ {result.Title}"; // Suffix the page title with the startPage name
                            }                        
                        }
                    }

                    return result;

                })
                .OrderBy(x => x.Metadata.TryGetValue("SortKey", out var sortKey) ? sortKey : string.Empty);

        }


        private new ITypeSearch<IContentData> GetFieldQuery(string SearchQuery, int maxResults)
        {

            var language = ResolveSupportedLanguageBasedOnPreferredCulture();

            if (String.IsNullOrEmpty(SearchQuery))
            {
                SearchQuery = "*";
            }

            var query = _searchClient.Search<IContentData>(language)
                .For(SearchQuery);

            if (StringExtensions.IsAbsoluteUrl(SearchQuery))
            {
                query = query.InField(x => ((ISearchContent)x).SearchHitUrl);
                return query
                .Take(maxResults);
            }

            query = query.InField(x => ((IContent)x).Name, 10)
                         .InField(x => ((IContent)x).ContentTypeName(), 0.5)
                         .InField(x => x.SearchText());

            int parsedQuery;
            if (int.TryParse(SearchQuery, out parsedQuery))
            {
                query = query.InField(x => ((IContent)x).ContentLink.ID, 10);
            }

            DateTime parsedDate;
            if (DateTime.TryParse(SearchQuery, out parsedDate))
            {
                query = query.InField(x => ((ISearchContent)x).SearchPublishDate.ToString());
            }            

            return query
                .Take(maxResults).SetTimeout(10000);
        }

        private IEnumerable<Type> GetContentTypesFromQuery(string parameter, Query query)
        {
            if (query.Parameters.ContainsKey(parameter))
            {
                var array = query.Parameters[parameter] as JArray;
                if (array != null)
                {
                    return array.Values<string>().SelectMany(GetContentTypes);
                }
            }
            return Enumerable.Empty<Type>();
        }

        private new IEnumerable<Type> GetContentTypes(string allowedType)
        {
            var uiDescriptor = _uiDescriptorRegistry.UIDescriptors.FirstOrDefault(d => d.TypeIdentifier.Equals(allowedType, StringComparison.OrdinalIgnoreCase));
            if (uiDescriptor == null)
                return Enumerable.Empty<Type>();

            return _contentTypeRepository
                .List()
                .Where(c => uiDescriptor.ForType.IsAssignableFrom(c.ModelType))
                .Select(c => c.ModelType);
        }

        private Language ResolveSupportedLanguageBasedOnPreferredCulture()
        {
            Language language = null;
            var preferredCulture = ContentLanguage.PreferredCulture;
            if (preferredCulture != null)
            {
                language = _searchClient.Settings.Languages.GetSupportedLanguage(preferredCulture);
            }
            language = language ?? Language.None;
            return language;
        }

    }
}

 

Jun 12, 2025

Comments

Cuong Nguyen Dinh
Cuong Nguyen Dinh Jun 12, 2025 01:53 PM

Cool technique. Thanks for sharing.

Stefan Holm Olsen
Stefan Holm Olsen Jun 15, 2025 04:01 AM

Great idea, Daniel.

Maybe something similar could be done for Commerce catalogues.

dada
dada Jun 16, 2025 05:47 PM

Hi Stefan

Good point. I put one together for Episerver.Labs.Find.Toolbox which I never pushed up. 

I could add this to that one and put it up here.

Ravindra S. Rathore
Ravindra S. Rathore Jun 17, 2025 04:21 PM

Great to see a solution for this day to day problem. Thanks for sharing

suzanmay
suzanmay Jun 20, 2025 09:53 AM

ORDER AUTHENTIC 100% REGISTERED IELTS,TOEFL,DIPLOMAS(+27(838-80-8170) VISAS,PASSPORTS,IDS/ Counterfeit Banknotes/

Vinit Gavankar
Vinit Gavankar Oct 16, 2025 12:32 PM

Nice workaround, Daniel :) . Is this compatible with CMS 11 as well? If not, can you please add your suggestions/changes required?

dada
dada Oct 16, 2025 05:40 PM

@Vinit Shouldn't need too much work to get it to work with CMS 11. Find 13 doesn't have async though so that needs to be changed.

error Please login to comment.
Latest blogs
Building UI Extensions for Optimizely SaaS CMS

Introduction Developers have been extending the functionality of Optimizely PaaS CMS for years using addons built in C# and deployed as NuGet...

Mark Stott | Oct 15, 2026

Cheapskate AI Translator: Because Translation Should Not Require a Second Mortgage

There is a particular kind of software project that begins with a detailed architecture document, a product roadmap, and a carefully assembled...

Linh Doan Cuu | Oct 15, 2026

Computed Fields in CMS 13 Content Contracts with Optimizely Graph

Content Contracts in Optimizely CMS 13 allow different content types to share a common structure when querying Optimizely Graph. But what if the...

Mardy | Oct 14, 2026

Opal Workflow Agents And The Graph Webhook Trap

What we did? We built a compliance review workflow agent in Opal. The job was simple: when an editor submits a page for review in Optimizely CMS 13...

Ritu Madan | Oct 14, 2026