Microsite Page Fallbacks in Sitecore

When companies start creating large numbers of microsites, it quickly becomes apparent that processes need to be put into place to prevent the duplication of content. One such strategy is to use page fallbacks to the primary site.

An example of the would be a financial services firm where each member of the firm has their own subdomain. Say the company is Great Advisors at the doman www.great-advisors.com. A member of the firm named John Smith might have the subdomain johnsmith.great-advisors.com.

Since the sub-domain is a Sitecore microsite, all of the content in the site navigation is separate by default. The allows the content administrator for the microsite to customize the entire site. Only the theme / building blocks (components) would be shared between them. But what about generic content that the two should share? Do we really need two "About the Firm" pages? This would quickly get out of hand with a large number of sites. This content should be updated in one location.

One good way around this is to implement a page fallback system. If a link is added to the microsite that doesn't exist, an item resolver will check if that relative url exists on the primary site. If so, it will return that content instead of a 404. So in the example above the following would happen:

  1. A user clicks the "About our Firm" link on the microsite. The link points to johnsmith.great-advisors.com/about-our-firm.
  2. The Sitecore item resolver confirms that the page doesn't exist in the microsite.
  3. A custom item resolver checks a custom Solr index for URLs in the parent site that match the '/about-our-firm' pattern. If it exists on the parent site, its returned to the microsite and the page loads correctly at johnsmith.great-advisors.com/about-our-firm

Code Running Behind the Scenes

The first thing we need to do is create the computed field. It holds the relative URL parts that the microsite searches for.

DisplayUrlComputedField.cs

namespace Bonfire.MultisitePageFallback.ComputedFields
{
    using Sitecore.ContentSearch;
    using Sitecore.Data.Items;
    using Sitecore.Diagnostics;
    using System;
    using Sitecore.Data;
    using Sitecore.ContentSearch.ComputedFields;
    using Sitecore.Sites;
    using Sitecore.Links;
    using Bonfire.DynamicSites;
    using Bonfire.DynamicSites.Services;
    using Bonfire.MultisitePageFallback.Helper;

    public class DisplayUrlComputedField : IComputedIndexField
    {
        public object ComputeFieldValue(IIndexable indexable)
        {
            var item = (Item)(indexable as SitecoreIndexableItem);
            if (item == null) return null;

            if (!item.Paths.FullPath.ToLower().StartsWith("/sitecore/content/PrimarySite/home"))
            {
                Log.Info("Not processing Url for Path - " + item.Paths.FullPath, this);
                return null;
            }

            Log.Info("Path - " + item.Paths.FullPath, this);

            string siteName = "";

            if (item.Paths.FullPath.ToLower().StartsWith("/sitecore/content/PrimarySite/home"))
                siteName = "PrimarySite";


            if (string.IsNullOrEmpty(siteName))
            {
                Log.Info("siteName was null for Path - " + item.Paths.FullPath, this);
                return null;
            }

            var website = Sitecore.Configuration.Factory.GetSite(siteName);
            var url = string.Empty;
            try
            {
                using (new SiteContextSwitcher(website))
                {
                    var options = LinkManager.GetDefaultUrlOptions();
                    options.AlwaysIncludeServerUrl = false;
                    options.LowercaseUrls = true;
                    options.SiteResolving = false;
                    //options.Site = website;
                    url = UrlParser.GetFormattedUrl(NormalizeUrl(LinkManager.GetItemUrl(item, options)));
                }
            }
            catch (Exception ex) { Log.Error("DisplayUrlComputedField - " + ex.Message, ex);}

            Log.Info("Url - " + url, this);

            return url;
        }

        private static string NormalizeUrl(string url)
        {
            if (url.StartsWith("://"))
            {
                url = url.Substring(url.IndexOf("/", 3, StringComparison.Ordinal));
            }

            return url;
        }
        public string FieldName { get; set; }

        public string ReturnType { get; set; }
    }
}

For the indexes to work correctly, we need to also create a custom search result class. This allows us to use linq more easily in the fallback processor.

PageFallbackSearchResult.cs

namespace Bonfire.MultisitePageFallback
{
    using Bonfire.Search;
    using Sitecore.ContentSearch;
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Text;
    using System.Threading.Tasks;

    public class PageFallbackSearchResult : SearchResultItemBase
    {
        [IndexField("displayurl")]
        public string DisplayUrl { get; set; }
    }
}

Next up we need to create a new processor in the HttpRequestBegin pipeline. We'll call it ItemNotFoundFallback.cs

namespace Bonfire.MultisitePageFallback
{
    using System.Linq;
    using System.Runtime.Remoting.Messaging;
    using PrimarySite.Caching;
    using Bonfire.MultisitePageFallback.Helper;
    using Sitecore;
    using Sitecore.ContentSearch;
    using Sitecore.ContentSearch.Linq;
    using Sitecore.Data;
    using Sitecore.Diagnostics;
    using Sitecore.Pipelines.HttpRequest;

    public class ItemNotFoundFallback : HttpRequestProcessor
    {
        private string TargetWebsite { get; set; }

        public override void Process(HttpRequestArgs args)
        {
            Assert.ArgumentNotNull(args, "args");

            if (args.Url.FilePathWithQueryString.StartsWith("/sitecore/shell") || args.Url.FilePathWithQueryString.StartsWith("/sitecore/service"))
                return;

            if (Context.Item != null)
                return;

            var site = Context.Database?.GetItem(Context.Site.ContentStartPath);
            if (site == null)
            {
                
                //Log.Info(__aSyNcId_<_UuqblWyk__quot;ItemNotFoundFallback: Could not find site for {args.Url.FilePathWithQueryString}. Context.Site.ContentStartPath was {Context.Site.ContentStartPath}", this);
                return;
            }

            var siteName = site["Parent Site"];
            if (string.IsNullOrEmpty(siteName))
            {
                //Log.Info(__aSyNcId_<_UuqblWyk__quot;ItemNotFoundFallback: Could not find the Parent site field for {args.Url.FilePathWithQueryString}. Context.Site.ContentStartPath was {Context.Site.ContentStartPath}", this);
                return;
            }

            var pageUrl = GetUrl(args);
            var cacheKey = "UrlFallback-" + siteName + pageUrl;


            var cacheResponse = CacheEngine.Get(cacheKey);
            ID itemId = null;
            if (cacheResponse == null)
            {
                var index = ContentSearchManager.GetIndex("sitecore_url_fallback_web_index");
                using (var context = index.CreateSearchContext())
                {
                    var results = context.GetQueryable<PageFallbackSearchResult>()
                        .Filter(x => x.SiteName == siteName)
                        .Filter(x => x.DisplayUrl == pageUrl)
                        .OrderBy(x => x.SortOrder);

                    var outcome = results.GetResults();

                    if (outcome.Any())
                    {
                        itemId = outcome.First().Document.ItemId;

                        CacheEngine.Set(itemId, cacheKey);
                        //Log.Info(__aSyNcId_<_UuqblWyk__quot;ItemNotFoundFallback: Cached the url for {siteName + pageUrl}.", this);

                    }
                    else
                    {
                        CacheEngine.Set(null, cacheKey);
                        //Log.Info(__aSyNcId_<_UuqblWyk__quot;ItemNotFoundFallback: 404-File Could not find the fall back url in search for {args.Url.FilePathWithQueryString}. localPath was {pageUrl}",this);
                    }
                }
            }
            else
            {
                var resp = (CacheResponse)cacheResponse;
                if (resp.ObjectExists)
                {
                    itemId = (ID)resp.Data;
                }
            }

            if (!ID.IsNullOrEmpty(itemId))
            {
                var returnItem = Context.Database.GetItem(itemId);
                Context.Item = returnItem;
            }


        }

        private string GetUrl(HttpRequestArgs args)
        {
            var localPath = args.LocalPath.ToLower();
            if (!localPath.EndsWith(".aspx"))
            {
                localPath += ".aspx";
            }

            localPath = UrlParser.GetFormattedUrl(localPath);

            return localPath;
        }
    }
}

You will see several log lines commented out in the code. They are helpful while debugging but can generate a lot of excess log data in production.

Now lets tie it all together with a custom config file:

MultisitePageFallback.config

<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <pipelines>
      <httpRequestBegin>
        <processor patch:before="processor[@type='Sitecore.Pipelines.HttpRequest.LayoutResolver, Sitecore.Kernel']"
                   type="Bonfire.MultisitePageFallback.ItemNotFoundFallback, Bonfire.MultisitePageFallback">
          <TargetWebsite>PrimarySite</TargetWebsite>
        </processor>
      </httpRequestBegin>
    </pipelines>
  </sitecore>
</configuration>

Deploy, rebuild the indexes, and you're done!

What about the Sitemaps?

Shortly after implementing this someone will ponder whether the 'about-our-firm' page should be in the primary site's sitemap, the microsite's sitemap, or both. The best answer is to add it to both and add a link to the alternate page via the rel=alternate syntax. The downside to this is that it greatly increases the complexity of your sitemaps but it should get around Google's search penalty for duplicate content across domains.