You can find a lot of blog posts on how to implement custom error pages with Sitecore. There are not many to cover also Sitecore JSS. So here it is! 🙂
When we are talking about Sitecore JSS, you have couple of topologies that you can use:
- integrated
- headless
There is a big difference between them but we won’t cover them here. Of course, in terms of implementing 404 or other error pages, there is also difference how to implement them for chosen topology.
As our implementation was using integrated topology / mode, we could implement 404 error pages more in a “Sitecore way” and not with node.js or proxy. But it would be boring blog post if things went well straight forward…
TL;DR
If you are already familiar with httpRequestBegin then you know that magic really happens here. What not many people / developers know, there is also mvc.getPageItem pipeline that you don’t need to handle in 99% of cases but when you experience problems that you set Context.Item your 404 page in httpRequestBegin and still you see different page then mvc.getPageItem pipeline is the next stop…
httpRequestBegin pipeline
When implementing handling of 404 or other error page in Sitecore in Sitecore MVC or Sitecore JSS (Integrated mode), we are usually enhancing httpRequestBegin pipeline with our own custom processor that is setting Context.Item to given 404 page.
In our case we have created 404 pages under Home items in Sitecore for various sites and the processor looked something like this:
using System.Linq;
using System.Net;
using System.Web;
using System.Web.Routing;
using Sitecore;
using Sitecore.Diagnostics;
using Sitecore.Pipelines.HttpRequest;
using Sitecore.XA.Foundation.SitecoreExtensions.Extensions;
namespace Chorpo.Foundation.SitecoreExtensions.Pipelines
{
public class ItemNotFound : HttpRequestProcessor
{
public override void Process(HttpRequestArgs args)
{
using (new PerformanceProfiler(nameof(ItemNotFound)))
{
if (Context.Item == null
&& Context.Site != null
&& Context.Database != null
&& !args.LocalPath.StartsWith("/identity/")
&& string.IsNullOrEmpty(Context.Page.FilePath)
&& RouteTable.Routes.GetRouteData(args.HttpContext) == null
&& !args.PermissionDenied)
{
Context.Item = Context.Database.GetItem(contextItemStartPath + "/404");
HttpContext.Current.Response.TrySkipIisCustomErrors = true;
HttpContext.Current.Response.StatusCode = (int)HttpStatusCode.NotFound;
}
}
}
}
}
And here is config entry that adds this custom processor to correct pipeline:
<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
<sitecore>
<pipelines>
<httpRequestBegin>
<processor type="Chorpo.Foundation.SitecoreExtensions.Pipelines.ItemNotFound, Chorpo.Foundation.SitecoreExtensions"
patch:before="processor[@type='Sitecore.XA.Feature.ErrorHandling.Pipelines.HttpRequestBegin.ItemNotFoundResolver, Sitecore.XA.Feature.ErrorHandling']" />
</httpRequestBegin>
</pipelines>
</sitecore>
</configuration>
As we have Sitecore SXA + JSS setup we have added our processor just before “Sitecore.XA.Feature.ErrorHandling.Pipelines.HttpRequestBegin.ItemNotFoundResolver, Sitecore.XA.Feature.ErrorHandling”. If you are not using SXA for some reason :-), then this should be placed after “Sitecore.Pipelines.HttpRequest.ItemResolver, Sitecore.Kernel”. Mind that with SXA it is before, without SXA it is after…
The problem
I have mentioned in TL;DR that in 99% cases you are at this point done.
When we were testing implementation all went through. Unfortunately, for one site and one language that was fall backing to en, this was not the case.
We have used wildcard items (asterisk *) here, therefore for some reason we were not getting 404 page which was set in Context.Item but this wildcard item.
At this point I have discovered after 8 years working with Sitecore that there is mvc.getPageItem pipeline. And as you can guess this was causing trouble. It was basically setting wildcard item for render and not Context.Item but let’s first see what mvc.getPageItem pipeline is doing.
mvc.getPageItem pipeline
According to Sitecore Documentation – this pipeline is one of MVC-specific pipelines (interesting called also within Sitecore JSS Integrated mode) and it resolves the item that was requested using route information. If the item cannot be resolved from the route, Context.Item
is used.
It has 4 important processors:
- SetLanguage – Sets the Context Language
- GetFromRouteValue – Sets the resulting item by replacing values within the URL with route data
- GetFromRouteUrl – Sets the resulting item by querying Sitecore with the URL path in relation to the Context.Site StartItem
- GetFromOldContext – Sets the resulting item by using the original Context.Item found within the httpRequestBegin
The resulting item or args.Result is then used to render specific item back to client for given url.
In most cases for 404, you end up in GetFromOldContext and the resulting item is taken from Context.Item. Case closed…
But not in our case 🙁
I have created couple of fake processors pre and post each of above mentioned to see what is coming inside as args.Result and what is coming out, basically to see in which processor the wildcard item is set instead of 404.
In one edge case I have found out there is problem in GetFromOldContext pipeline. GetFromRouteValue pipeline identified the Sitecore item based on url so it set args.Result to wildcard item but in GetFromOldContext pipeline this was not reset to Context.item but stayed.
The reason was this code (we are on Sitecore 10.0 Update 1 a.k.a. Sitecore 10.0.1) so your code might vary:
public override void Process([NotNull] GetPageItemArgs args)
{
Assert.ArgumentNotNull(args, "args");
if (args.Result == null || (GetContextItem() != null && args.Result.Version.Number != GetContextItem().Version.Number))
{
args.Result = ResolveItem(args);
}
}
And especially this condition args.Result.Version.Number != GetContextItem().Version.Number. In our case args.Result was set to wildcard item and Context.Item was set to 404 page but because they had same version (version 1) in this case. Context.Item was not set as args.Result.
We could create another version and publish content to solve this issue but developers know, this wouldn’t be proper fix 😉
Solution
Solution was to add another processor into mvc.getPageItem pipeline just before other processors (particularly Sitecore.Mvc.Pipelines.Response.GetPageItem.GetFromRouteValue) are setting args.Result and which would also check whether flag Context.Items[“handling_404”] = true is set.
So the original ItemNotFound was enhance with this simple line:
using System.Linq;
using System.Net;
using System.Web;
using System.Web.Routing;
using Sitecore;
using Sitecore.Diagnostics;
using Sitecore.Pipelines.HttpRequest;
using Sitecore.XA.Foundation.SitecoreExtensions.Extensions;
namespace Chorpo.Foundation.SitecoreExtensions.Pipelines
{
public class ItemNotFound : HttpRequestProcessor
{
public override void Process(HttpRequestArgs args)
{
using (new PerformanceProfiler(nameof(ItemNotFound)))
{
if (Context.Item == null
&& Context.Site != null
&& Context.Database != null
&& !args.LocalPath.StartsWith("/identity/")
&& string.IsNullOrEmpty(Context.Page.FilePath)
&& RouteTable.Routes.GetRouteData(args.HttpContext) == null
&& !args.PermissionDenied)
{
Context.Item = Context.Database.GetItem(contextItemStartPath + "/404");
Context.Items["handling_404"] = true;
HttpContext.Current.Response.TrySkipIisCustomErrors = true;
HttpContext.Current.Response.StatusCode = (int)HttpStatusCode.NotFound;
}
}
}
}
}
New processor was added into mvc.getPageItem pipeline:
<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
<sitecore>
<pipelines>
<mvc.getPageItem>
<processor type="Chorpo.Foundation.SitecoreExtensions.Pipelines.GetPageItem.Handle404, Chorpo.Foundation.SitecoreExtensions"
patch:before="processor[@type='Sitecore.Mvc.Pipelines.Response.GetPageItem.GetFromRouteValue, Sitecore.Mvc']"
resolve="true" />
</mvc.getPageItem>
</pipelines>
</sitecore>
</configuration>
and this is the code of the new processor to handle 404 pages:
using Sitecore;
using Sitecore.Abstractions;
using Sitecore.Diagnostics;
using Sitecore.Mvc.Pipelines.Response.GetPageItem;
namespace Chorpo.Foundation.SitecoreExtensions.Pipelines.GetPageItem
{
public class Handle404 : GetPageItemProcessor
{
public Handle404(BaseClient baseClient)
: base(baseClient)
{
}
public override void Process(GetPageItemArgs args)
{
Assert.ArgumentNotNull(args, "args");
var contextItem = GetContextItem();
if (args.Result == null && contextItem != null && Context.Items.Contains("handling_404"))
{
args.Result = contextItem;
}
}
}
}
Happy Sitecoring my friends!