World is now on Opti ID! Learn more

dada
Jun 12, 2025
  477
(5 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/

Please login to comment.
Latest blogs
Troubleshooting Optimizely Shortcuts: Why PageShortcutLink Threw an Error and the Solution

As developers working with Optimizely, we often encounter unique challenges that push us to explore the platform's depths. Recently, I tackled a...

Amit Mittal | Jul 9, 2025

Commerce 14.41 delisted from Nuget feed

We have decided to delist version 14.41 of the Commerce packages as we have discovered a bug that prevents current carts to be saved into...

Shahrukh Ikhtear | Jul 8, 2025

How Optimizely SaaS CMS Isn’t Just Another Commodity

CMS platforms these days are becoming commoditised. The modelling of most systems lends itself to automation. Giving marketers more flexibility wit...

Chuhukon | Jul 4, 2025 |

How to Set Up CI/CD Pipeline for Optimizely Frontend Hosting Using GitHub Actions

As I promised in my previous blog post about getting started with Optimizely Frontend Hosting, today I’m delivering on that promise by sharing how ...

Szymon Uryga | Jul 2, 2025

Environment-specific badges in the Optimizely CMS

Step 1: Add Environment Info to the UI Create a custom UIDescriptor and a ComponentDefinition to inject a badge into the CMS UI. using EPiServer.Sh...

Rajveer Singh | Jul 1, 2025

Boosting by published date with Relevance

Goal: To ensure that the latest published or last updated content is ranked higher in your search results, you can modify your query to include a ...

Rajveer Singh | Jul 1, 2025