World is now on Opti ID! Learn more

dada
Jun 12, 2025
  0
(0 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

Please login to comment.
Latest blogs
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 co...

dada | Jun 26, 2025

The remote server returned an error: (400) Bad Request – when configuring Azure Storage for an older Optimizely CMS site

How to fix a strange issue that occurred when I moved editor-uploaded files for some old Optimizely CMS 11 solutions to Azure Storage.

Tomas Hensrud Gulla | Jun 26, 2025 |

Enable Opal AI for your Optimizely products

Learn how to enable Opal AI, and meet your infinite workforce.

Tomas Hensrud Gulla | Jun 25, 2025 |

Deploying to Optimizely Frontend Hosting: A Practical Guide

Optimizely Frontend Hosting is a cloud-based solution for deploying headless frontend applications - currently supporting only Next.js projects. It...

Szymon Uryga | Jun 25, 2025

World on Opti ID

We're excited to announce that world.optimizely.com is now integrated with Opti ID! What does this mean for you? New Users:  You can now log in wit...

Patrick Lam | Jun 22, 2025

Avoid Scandinavian Letters in File Names in Optimizely CMS

Discover how Scandinavian letters in file names can break media in Optimizely CMS—and learn a simple code fix to automatically sanitize uploads for...

Henning Sjørbotten | Jun 19, 2025 |