Sitecore Experience Accelerator (SXA) comes with a robust error handling module that handles item not found and server errors. It allows you to configure a custom page for both scenarios, per site, and handles 404s without a redirect. However, the SXA error handling module does not handle two other scenarios: media and layout not found.

You can test media not found on your site by navigating to /-/media/blah-blah-blah, and layout not found by navigating to /data (assuming you have a Data folder under your home item). In both scenarios, Sitecore does not use the Page Not Found Link configured on your site's settings, and you'll notice a 302 redirect to the Sitecore 404 page which is not ideal. Read on to learn how to fix this.

TL;DR

All of the code for this tutorial is on GitHub here: https://github.com/coreyasmith/sxa-media-and-layout-not-found.

Note that the code in this post is slightly different than the code in the above repository for brevity.

Shared Infrastructure

From this point I'm assuming that you have the Error Handling module installed on your site(s) in SXA and that you've configured the Page Not Found Link on your site's Settings item. This is a prerequisite for this code to work. If you haven't configured it yet, check out Saad "Sitecore Sam" Ansari's blog post on setting up not found pages with SXA.

Both the media and layout not found scenarios require some shared code so let's tackle that first. You'll need the following NuGet packages for this post:

  • Microsoft.Extensions.DependencyInjection.Abstractions (1.0.0)
  • Sitecore.Kernel.NoReferences (9.0.180604)
  • Sitecore.Mvc.NoReferences (9.0.180604)
  • Sitecore.XA.Feature.ErrorHandling (3.7.1)
  • Sitecore.XA.Foundation.Abstractions (3.7.1)
  • Sitecore.XA.Foundation.MediaRequestHandler (3.7.1)
  • Sitecore.XA.Foundation.Multisite (3.7.1)

Create the following IContextSite interface:

using Sitecore.Data.Items;

namespace CoreySmith.Feature.ErrorHandling.Sites
{
  public interface IContextSite
  {
    Item SiteItem { get; }
    Item SettingsItem { get; }
  }
}

and its implementation:

using System;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Sites;
using Sitecore.XA.Foundation.Abstractions;
using Sitecore.XA.Foundation.Multisite;

namespace CoreySmith.Feature.ErrorHandling.Sites
{
  public class ContextSite : IContextSite
  {
    private readonly IContext _context;
    private readonly IMultisiteContext _multisiteContext;

    private Database ContentDatabase => _context.ContentDatabase ?? _context.Database;

    public Item SiteItem => GetSiteItem(_context.Site);
    public Item SettingsItem => GetSettingsItem(_context.Site);

    public ContextSite(IContext context, IMultisiteContext multisiteContext)
    {
      _context = context ?? throw new ArgumentNullException(nameof(context));
      _multisiteContext = multisiteContext ?? throw new ArgumentNullException(nameof(multisiteContext));
    }

    private Item GetSiteItem(SiteContext site)
    {
      return site == null ? null : ContentDatabase.GetItem(site.RootPath);
    }

    private Item GetSettingsItem(SiteContext site)
    {
      var siteItem = GetSiteItem(site);
      return siteItem == null ? null : _multisiteContext.GetSettingsItem(siteItem);
    }
  }
}

SXA has a nice IMultisiteContext abstraction that you can use to access common items for the context site of the request like the tenant item, site root item, settings item, etc. However, IMultisiteContext only works if the context item has been resolved for the request. When the media item is not found there will be no context item; this ContextSite service will give us easy access to the context site's Settings item in that scenario.

Create the following IErrorItemResolver interface:

using Sitecore.Data.Items;

namespace CoreySmith.Feature.ErrorHandling.Services
{
  public interface IErrorItemResolver
  {
    Item GetNotFoundItem();
  }
}

and its implementation:

using System;
using CoreySmith.Feature.ErrorHandling.Sites;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;
using SxaErrorHandlingTemplates = Sitecore.XA.Feature.ErrorHandling.Templates;

namespace CoreySmith.Feature.ErrorHandling.Services
{
  public class ErrorItemResolver : IErrorItemResolver
  {
    private readonly IContextSite _contextSite;

    public ErrorItemResolver(IContextSite contextSite)
    {
      _contextSite = contextSite ?? throw new ArgumentNullException(nameof(contextSite));
    }

    public Item GetNotFoundItem()
    {
      var notFoundItem = GetErrorItem(SxaErrorHandlingTemplates._ErrorHandling.Fields.Error404Page.ToString());
      return notFoundItem;
    }

    private Item GetErrorItem(string fieldId)
    {
      var settingsItem = GetContextSiteSettingsItem();
      ReferenceField errorItemField = settingsItem?.Fields[fieldId];
      return errorItemField?.TargetItem;
    }

    private Item GetContextSiteSettingsItem()
    {
      return _contextSite.SettingsItem;
    }
  }
}

The ErrorItemResolver is used in both the media and layout not found scenarios to resolve the Page Not Found Link item on the context site's Settings item.

Register these services with Sitecore's container:

using CoreySmith.Feature.ErrorHandling.Services;
using CoreySmith.Feature.ErrorHandling.Sites;
using Microsoft.Extensions.DependencyInjection;
using Sitecore.DependencyInjection;

namespace CoreySmith.Feature.ErrorHandling
{
  public class ErrorHandlingConfigurator : IServicesConfigurator
  {
    public void Configure(IServiceCollection serviceCollection)
    {
      serviceCollection.AddSingleton<IContextSite, ContextSite>();
      serviceCollection.AddSingleton<IErrorItemResolver, ErrorItemResolver>();
    }
  }
}

Wire up the configurator with Sitecore through a config patch:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <services>
      <configurator type="CoreySmith.Feature.ErrorHandling.ErrorHandlingConfigurator, CoreySmith.Feature.ErrorHandling" />
    </services>
  </sitecore>
</configuration>

Now for media not found.

Handle Media Not Found

SXA has a special pipeline for resolving media items: <mediaRequestHandler />. At the end of this pipeline is a processor, HandleErrors, that causes the 302 redirect to the Sitecore 404 page. It also handles media security so that if a visitor doesn't have permission to access an item in the media library, they'll get redirected to the login page for the site.

We're going to create two new processors to handle media not found and insert them both in the <mediaRequestHandler /> pipeline right before the HandleErrors processor: one to handle permission denied and one to handle media not found.

Permission Denied Processor

Create this processor to handle permission denied:

using System;
using System.Web;
using Sitecore.Resources.Media;
using Sitecore.SecurityModel;
using Sitecore.XA.Foundation.Abstractions;
using Sitecore.XA.Foundation.MediaRequestHandler.Pipelines.MediaRequestHandler;

namespace CoreySmith.Feature.ErrorHandling.Pipelines.MediaRequestHandler
{
  public class HandlePermissionDenied : ProcessMediaRequestProcessor
  {
    private readonly IContext _context;

    public HandlePermissionDenied(IContext context)
    {
      _context = context ?? throw new ArgumentNullException(nameof(context));
    }

    public override void Process(MediaRequestHandlerArgs handlerArgs)
    {
      if (handlerArgs.Media != null) return;
      if (!PermissionDenied(handlerArgs)) return;

      var loginUrl = GetLoginUrl();
      if (string.IsNullOrWhiteSpace(loginUrl)) return;
      
      HttpContext.Current.Response.Redirect(loginUrl);
      handlerArgs.Result = true;
      handlerArgs.AbortPipeline();
    }

    private static bool PermissionDenied(MediaRequestHandlerArgs handlerArgs)
    {
      using (new SecurityDisabler())
      {
        handlerArgs.Media = MediaManager.GetMedia(handlerArgs.Request.MediaUri);
      }
      return handlerArgs.Media != null;
    }

    private string GetLoginUrl()
    {
      return _context.Site?.LoginPage;
    }
  }
}

This processor will run at the end of the <mediaRequestHandler /> pipeline and resolve the media item for the request with a SecurityDisabler to see if the media item couldn't be resolved due to insufficient permissions. If that's the case, it'll redirect the user to the site's login page. If not, the next processor is going to do its thing.

Media Not Found Processor

Create this processor to handle media not found:

using System;
using System.Net;
using System.Web;
using CoreySmith.Feature.ErrorHandling.Services;
using Sitecore.Abstractions;
using Sitecore.Data.Items;
using Sitecore.Web;
using Sitecore.XA.Foundation.MediaRequestHandler.Pipelines.MediaRequestHandler;

namespace CoreySmith.Feature.ErrorHandling.Pipelines.MediaRequestHandler
{
  public class HandleNotFound : ProcessMediaRequestProcessor
  {
    private readonly IErrorItemResolver _errorItemResolver;
    private readonly BaseLinkManager _linkManager;

    public HandleNotFound(IErrorItemResolver errorItemResolver, BaseLinkManager linkManager)
    {
      _errorItemResolver = errorItemResolver ?? throw new ArgumentNullException(nameof(errorItemResolver));
      _linkManager = linkManager ?? throw new ArgumentNullException(nameof(linkManager));
    }

    public override void Process(MediaRequestHandlerArgs handlerArgs)
    {
      if (handlerArgs.Media != null) return;

      var notFoundUrl = GetNotFoundUrl();
      if (string.IsNullOrWhiteSpace(notFoundUrl)) return;
      
      HttpContext.Current.Server.TransferRequest(notFoundUrl);
      handlerArgs.Result = true;
      handlerArgs.AbortPipeline();
    }

    private string GetNotFoundUrl()
    {
      var notFoundItem = _errorItemResolver.GetNotFoundItem();
      if (notFoundItem == null) return null;

      var baseUrl = GetItemUrl(notFoundItem);
      var queryString = GetStatusCodeQueryString();
      return WebUtil.AddQueryString(baseUrl, queryString);
    }

    private string GetItemUrl(Item item)
    {
      var url = _linkManager.GetItemUrl(item);
      return url;
    }

    private static string[] GetStatusCodeQueryString()
    {
      return new[]
      {
        HttpUtility.UrlEncode(Constants.MediaRequestStatusCodeKey),
        HttpStatusCode.NotFound.ToString()
      };
    }
  }
}

This processor is going to execute after the HandlePermissionDenied processor above. If this processor executes, we know that the media item wasn't found because it doesn't exist, not because of insufficient permissions. It uses the IErrorItemResolver we created to get the Page Not Found Link item for the current site, and then builds up a URL for the not found page.

Create the following constant:

public struct Constants
{
  public static string MediaRequestStatusCodeKey = $"CoreySmith.Feature.ErrorHandling::{Guid.NewGuid()}";
}

We use this constant in the next processor we're going to build to set a 404 status code on the response. This constant is a static string that generates a new Guid each time the application starts up. If the site's error page is at /not-found, the HandleNotFound processor will generate the redirect URL as /not-found?CoreySmith.Feature.ErrorHandling::{some-guid-here}=404, where {some-guid-here} is different each time Sitecore starts. The processor we write next will look for the CoreySmith.Feature.ErrorHandling::{some-guid-here} query string, and set the response status code to its value--404 in this case.

The reason we use Guid.NewGuid() in this key is to prevent outside visitors from triggering the processor we write next by just adding CoreySmith.Feature.ErrorHandling=404 in the query string. No outside visitor can guess the query string key.

Patch these two processors in to the <mediaRequestHandler /> pipeline just before the HandleErrors processor:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <pipelines>
      <mediaRequestHandler>
        <processor type="CoreySmith.Feature.ErrorHandling.Pipelines.MediaRequestHandler.HandlePermissionDenied, CoreySmith.Feature.ErrorHandling"
                   resolve="true"
                   patch:before="processor[@type='CoreySmith.Feature.ErrorHandling.Pipelines.MediaRequestHandler.HandleNotFound, CoreySmith.Feature.ErrorHandling']" />
        <processor type="CoreySmith.Feature.ErrorHandling.Pipelines.MediaRequestHandler.HandleNotFound, CoreySmith.Feature.ErrorHandling"
                   resolve="true"
                   patch:before="processor[@type='Sitecore.XA.Foundation.MediaRequestHandler.Pipelines.MediaRequestHandler.HandleErrors, Sitecore.XA.Foundation.MediaRequestHandler']" />
      </mediaRequestHandler>
    </pipelines>
  </sitecore>
</configuration>

Notice that the HandlePermissionDenied processor is patched before our HandleNotFound processor. This order is important.

⚠️WARNING⚠️: This patch file must load after the ~/App_Config/Include/Foundation/Sitecore.XA.Foundation.MediaRequestHandler.config patch file. Either place it in ~/App_Config/Include/z.Feature or create a custom layer as I've done here. If you add this patch to ~/App_Config/Include/Feature, these processors will get patched at the beginning of the <mediaRequestHandler /> pipeline and throw NullReferenceExceptions.

Set Status Code

Create this processor to set the status code on the response:

using System;
using System.Net;
using Sitecore.Pipelines.HttpRequest;

namespace CoreySmith.Feature.ErrorHandling.Pipelines.HttpRequestProcessed
{
  public class SetMediaRequestStatusCode : HttpRequestProcessor
  {
    public override void Process(HttpRequestArgs args)
    {
      var httpStatus = args.HttpContext.Request.QueryString[Constants.MediaRequestStatusCodeKey];
      if (string.IsNullOrEmpty(httpStatus)) return;

      if (!Enum.TryParse<HttpStatusCode>(httpStatus, out var httpStatusCode)) return;
      if (httpStatusCode != HttpStatusCode.NotFound) return;

      args.HttpContext.Response.StatusCode = (int)httpStatusCode;
    }
  }
}

This processor looks for the presence of the query string we created above and sets the response status code to its value if it has been set to 404.

SXA ships with a processor to set the status code when the item is not found (and we'll use it for layout not found, too), but it won't work for media not found. That processor looks for the status code in the Sitecore.Context.Items collection; however, when you do HttpContext.Current.Server.Transfer like we are in the HandleNotFound processor, all items in that collection are erased after the transfer (unfortunately HttpContext.Current.Items is affected, too), so query string is the only option to communicate between the HandleNotFound and SetMediaStatusCode processors.

Patch this in to the <httpRequestProcessed /> pipeline right after SXA's SetStatusCode processor:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <pipelines>
      <httpRequestProcessed>
        <processor type="CoreySmith.Feature.ErrorHandling.Pipelines.HttpRequestProcessed.SetMediaRequestStatusCode, CoreySmith.Feature.ErrorHandling"
                   patch:before="processor[@type='Sitecore.XA.Feature.ErrorHandling.Pipelines.HtpRequestProcessed.SetStatusCode, Sitecore.XA.Feature.ErrorHandling']" />
      </httpRequestProcessed>
    </pipelines>
  </sitecore>
</configuration>

Again, patch this in ~/App_Config/Include/z.Feature to make sure the SetMediaRequestStatusCode processor is patched in the correct order.

Now you should have a working solution. Deploy this code to your site and navigate to /-/media/blah-blah-blah. You should now see your site's not found page with a 404 status code and no redirect.

Handle Layout Not Found

Handling layout not found requires two processors: one in <httpRequestBegin /> and one in <mvc.getPageItem />. This scenario is a bit different than item/media not found--in this case, an item has been found by Sitecore's item resolver (and Sitecore.Context.Item has been set), but the item doesn't have a layout to show any content.

Layout Not Found Processor

Create this processor to handle layout not found:

using System;
using System.Net;
using System.Web.Routing;
using CoreySmith.Feature.ErrorHandling.Services;
using Sitecore.Data.Items;
using Sitecore.Pipelines.HttpRequest;
using Sitecore.XA.Foundation.Abstractions;

namespace CoreySmith.Feature.ErrorHandling.Pipelines.HttpRequestBegin
{
  public class HandleLayoutNotFound : HttpRequestProcessor
  {
    private readonly IContext _context;
    private readonly IErrorItemResolver _errorItemResolver;

    public HandleLayoutNotFound(IContext context, IErrorItemResolver errorItemResolver)
    {
      _context = context ?? throw new ArgumentNullException(nameof(context));
      _errorItemResolver = errorItemResolver ?? throw new ArgumentNullException(nameof(errorItemResolver));
    }

    public override void Process(HttpRequestArgs args)
    {
      if (AbortProcessor(args)) return;

      var notFoundItem = _errorItemResolver.GetNotFoundItem();
      if (notFoundItem == null) return;
      
      var layoutFilePath = notFoundItem.Visualization?.Layout?.FilePath;
      if (string.IsNullOrEmpty(layoutFilePath)) return;

      _context.Item = notFoundItem;
      _context.Page.FilePath = layoutFilePath;
      _context.Items["httpStatus"] = (int)HttpStatusCode.NotFound;
      _context.Items[Constants.CustomContextItemKey] = true;
      args.HttpContext.Response.TrySkipIisCustomErrors = true;
    }

    private bool AbortProcessor(HttpRequestArgs args)
    {
      if (_context.Site == null) return true;
      if (_context.Database == null) return true;
      if (!string.IsNullOrEmpty(_context.Page.FilePath)) return true;
      if (RouteTable.Routes.GetRouteData(args.HttpContext) != null) return true;
      if (args.PermissionDenied) return true;
      return false;
    }

    private void Processed(Item notFoundItem)
    {
      var layoutFilePath = notFoundItem?.Visualization?.Layout?.FilePath;
      if (string.IsNullOrEmpty(layoutFilePath)) return;

      _context.Page.FilePath = layoutFilePath;
    }
  }
}

This processor checks a lot of preconditions before executing--the most important one is !string.IsNullOrEmpty(_context.Page.FilePath) return true;. If the context page has a non-null FilePath, it has a layout and this processor shouldn't execute. (RouteTable.Routes.GetRouteData(args.HttpContext) != null) return true; is also key--if a specific MVC route is being executed where presentation doesn't matter (e.g., for an AJAX request), we shouldn't interfere.

If we encounter an item with no layout, this processor: uses the IErrorItemResolver from above to get the Page Not Found Link item for the context site; overwrites the Sitecore.Context.Item with the not found item; sets the Sitecore.Context.Page.FilePath we checked earlier to the layout file path of the not found item; and sets the HTTP status code to 404 for the SXA SetStatusCode processor.

The SXA SetStatusCode processor in the <httpRequestProcessed /> pipeline will set the response status code to whatever value is set in Sitecore.Context.Items["httpStatusCode"].

You'll notice that we're also setting a flag in _context.Items--Constants.CustomContextItemKey. We'll tackle its purpose in the next processor, but go ahead and create the constant now:

public struct Constants
{
  public const string CustomContextItemKey = "CoreySmith.Feature.ErrorHandling::CustomContextItem";
}

Patch this processor in to the <httpRequestBegin /> pipeline after the Sitecore LayoutResolver:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <pipelines>
      <httpRequestBegin>
        <processor type="CoreySmith.Feature.ErrorHandling.Pipelines.HttpRequestBegin.HandleLayoutNotFound, CoreySmith.Feature.ErrorHandling"
                   resolve="true"
                   patch:after="processor[@type='Sitecore.Pipelines.HttpRequest.LayoutResolver, Sitecore.Kernel']" />
      </httpRequestBegin>
    </pipelines>
  </sitecore>
</configuration>

On to the last piece.

Reset Context Item Processor

As I mentioned above, layout not found is different than item/media not found because an item has been found, it just doesn't have a layout. If you change the Context.Item after it's been set by the ItemResolver, it'll actually get resolved again and reset to the original Context.Item in the <mvc.getPageItem /> pipeline. Pavel Veller has blogged about this behavior, so has Kamruz Jaman on his blog, and Kyle Kingsbury on his. Check those posts out for more info.

Create the following processor to get around this issue:

using System;
using Sitecore.Mvc.Pipelines.Response.GetPageItem;
using Sitecore.XA.Foundation.Abstractions;

namespace CoreySmith.Feature.ErrorHandling.Pipelines.Mvc.GetPageItem
{
  public class GetFromContextItem : GetPageItemProcessor
  {
    private readonly IContext _context;

    public GetFromContextItem(IContext context)
    {
      _context = context ?? throw new ArgumentNullException(nameof(context));
    }

    public override void Process(GetPageItemArgs args)
    {
      if (AbortProcessor(args)) return;
      args.Result = _context.Item;
    }

    private bool AbortProcessor(GetPageItemArgs args)
    {
      if (!_context.Items.Contains(Constants.CustomContextItemKey)) return true;
      if (_context.Item == null) return true;
      if (args.Result != null) return true;
      return false;
    }
  }
}

This checks for the flag we set in Sitecore.Context.Items and sets the result of the pipeline to the current context item. This way the context item won't be resolved again and reset later in the pipeline.

Patch this in to the <mvc.getPageItem /> pipeline:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <pipelines>
      <mvc.getPageItem>
        <processor type="CoreySmith.Feature.ErrorHandling.Pipelines.Mvc.GetPageItem.GetFromContextItem, CoreySmith.Feature.ErrorHandling"
                   resolve="true"
                   patch:before="processor[@type='Sitecore.Mvc.Pipelines.Response.GetPageItem.GetFromRouteUrl, Sitecore.Mvc']" />
      </mvc.getPageItem>
    </pipelines>
  </sitecore>
</configuration>

Make sure you patch before GetFromRouteUrl--if you patch after, this processor won't work.

Deploy this code and navigate to an item in your content tree with no layout such as /data. You should now see your site's not found page with a 404 status code and no redirect.

Conclusion

If you've followed along, your Sitecore instance now gracefully handles media not found and layout not found using SXA's Error Handling module. We've implemented everything here in one module to keep the post shorter, but check out my repo on GitHub to see services broken out better according to Helix principles.

Let me know your thoughts in the comments!