Sitecore Headless Services - Handle Layout Not Found for the REST Layout Service


Sitecore’s REST Layout Service has inconsistent behavior for items that don’t exist and items with no layout (e.g., a page’s Data folder).
For items that don’t exist, the Layout Service returns a 404 status code with this response body:
{
"sitecore": {
"context": {
"pageEditing": false,
"language": "en"
},
"route": null
}
}
The JavaScript Services (JSS) Next.js SDK handles this with no issue and renders the configured 404 page.
However, for items with no layout, the Layout Service returns a 404 with no response body. This causes the JSS Next.js SDK to return a 500 error page instead of a 404 page, and log server console errors similar to this:
GET /data 500 in 78ms (next.js: 62ms, proxy.ts: 7ms, application-code: 9ms)
⨯ TypeError: Cannot read properties of undefined (reading 'route')
at NormalModePlugin.exec (src\lib\page-props-factory\plugins\normal-mode.ts:39:36)
at async eval (src\lib\page-props-factory\index.ts:46:26)
at async eval (src\lib\page-props-factory\index.ts:45:23)
at async eval (src\lib\page-props-factory\index.ts:45:23)
at async SitecorePagePropsFactory.create (src\lib\page-props-factory\index.ts:42:27)
at async getStaticProps (src\pages\[[...path]].tsx:85:17)
37 | );
38 |
> 39 | if (!props.layoutData.sitecore.route) {
| ^
40 | // A missing route value signifies an invalid path, so set notFound.
41 | // Our page routes will return this in getStatic/ServerSideProps,
42 | // which will trigger our custom 404 page with proper 404 status code.
This is only a problem when using the REST Layout Service. The GraphQL Layout Service, introduced with Sitecore 10.1, handles items with no layout the same way it handles items that don’t exist. When using the GraphQL Layout Service, the JSS Next.js SDK does not return a 500 error page for items with no layout; it correctly returns the 404 page.
On versions of Sitecore prior to 10.2, the Placeholder endpoint also has inconsistent behavior for items that don’t exist and items with no layout. For items that don’t exist, the Placeholder endpoint returns a 404 with no body (or an IIS error page in the case of Sitecore 9.0 Update-2). For items with no layout, the Placeholder endpoint returns a 200 status code with this response body:
{
"name": null,
"path": "main",
"elements": []
}
The Component endpoint, introduced in Sitecore 10.4 at /sitecore/api/layout/component/jss, also has inconsistent behavior for items that don’t exist and items with no layout. For items that don’t exist, the Component endpoint returns a 404 with the same body as the render endpoint:
{
"sitecore": {
"context": {
"pageEditing": false,
"language": "en"
},
"route": null
}
}
For items with no layout, the Component endpoint returns a 404 with no body.
Sitecore is inconsistent in how it handles layout not found just about everywhere. Let’s fix it.
Switch to the GraphQL Layout Service
If you’re on Sitecore 10.1 or later, the simplest fix is to switch to the GraphQL Layout Service. For JSS 20 and newer, just set FETCH_WITH=GraphQL in your head app’s environment variables. For JSS 18 and 19, return the GraphQLLayoutService from the LayoutServiceFactory.
You won’t be able to switch to the GraphQL Layout Service if:
- You’re on Sitecore 10.0 Update-3 or earlier.
- You’re running Sitecore XP and need tracking or tracking-based personalization.
- Testing a switch to the GraphQL Layout Service is out of the question for your team.
If you can’t switch to the GraphQL Layout Service, you will patch Sitecore. The patch differs depending on your version of Sitecore.
If you are on Sitecore 10.1 or earlier and use the Placeholder endpoint, or you use the Component endpoint, you will patch Sitecore. There is no GraphQL equivalent for those endpoints. The patches below fix the Placeholder and Component endpoints as well as the REST Layout Service.
Code samples can be found on GitHub: https://github.com/coreyasmith/sitecore-headless-services-rest-layout-not-found.
Fix It in the Head App
You can. Changing layoutData.sitecore.route to layoutData.sitecore?.route in the affected spots (e.g., page-props-factory.ts, normal-mode.ts, component-props.ts) will make your head app return a 404 instead of 500. But it doesn’t fix the root cause, and it doesn’t fix the inconsistency in the Placeholder and Component endpoints.
My recommendation is to fix the actual bug in the Layout Service as detailed below.
Sitecore 10.4 and 10.5
Create a decorator for Sitecore’s IResponseBuilder implementation:
using System;
using Sitecore;
using Sitecore.Data.Items;
using Sitecore.LayoutService.Configuration;
using Sitecore.LayoutService.Mvc.Factories.Interfaces;
using Sitecore.LayoutService.Mvc.Request.Models;
using Sitecore.LayoutService.Mvc.Response.Interfaces;
using Sitecore.Mvc.Presentation;
namespace CoreySmith.Feature.LayoutService.Response
{
public class LayoutNotFoundResponseBuilder : IResponseBuilder
{
private readonly IResponseBuilder _innerBuilder;
private readonly IRenderingViewFactory _renderingViewFactory;
public LayoutNotFoundResponseBuilder(IResponseBuilder responseBuilder, IRenderingViewFactory renderingViewFactory)
{
_innerBuilder = responseBuilder ?? throw new ArgumentNullException(nameof(responseBuilder));
_renderingViewFactory = renderingViewFactory ?? throw new ArgumentNullException(nameof(renderingViewFactory));
}
public Item ResolveItem(ItemRequestModel itemModel)
{
var item = _innerBuilder.ResolveItem(itemModel);
if (item == null) return null;
using (new ContextItemSwitcher(item))
{
if (_renderingViewFactory.GetRenderingView() != null)
{
return item;
}
}
// Clear Context.Item so that site context is not added to Layout Service response
Context.Item = null;
return null;
}
public NamedConfiguration ResolveConfiguration(ConfigurationRequestModel configurationModel)
{
return _innerBuilder.ResolveConfiguration(configurationModel);
}
public Rendering ResolveRendering(ComponentRequestModel componentModel)
{
return _innerBuilder.ResolveRendering(componentModel);
}
}
}
IRenderingViewFactory.GetRenderingView() is what Headless Services uses to determine if an item has layout. If the resolved item does not have layout, ResolveItem returns null, the same as for an item that does not exist.
Context.Item must be set to null before returning the result; otherwise, the site context will be added to the Layout Service response like this:
{
"sitecore": {
"context": {
"pageEditing": false,
"site": {
"name": "website"
},
"pageState": "normal",
"language": "en"
},
"route": null
}
}
Decorate Sitecore’s IResponseBuilder with the LayoutNotFoundResponseBuilder. I recommend Scrutor’s Decorate extension for this with Microsoft.Extensions.DependencyInjection:
using CoreySmith.Feature.LayoutService.Response;
using Microsoft.Extensions.DependencyInjection;
using Sitecore.DependencyInjection;
using Sitecore.LayoutService.Mvc.Response.Interfaces;
namespace CoreySmith.Feature.LayoutService
{
public class LayoutServiceConfigurator : IServicesConfigurator
{
public void Configure(IServiceCollection serviceCollection)
{
serviceCollection.Decorate<IResponseBuilder, LayoutNotFoundResponseBuilder>();
}
}
}
And patch it into Sitecore config:
<?xml version="1.0" encoding="utf-8"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
<sitecore>
<services>
<configurator type="CoreySmith.Feature.LayoutService.LayoutServiceConfigurator, CoreySmith.Feature.LayoutService" />
</services>
</sitecore>
</configuration>
Deploy to your Sitecore instance and enjoy.
Sitecore 10.2 and 10.3
Create a decorator for Sitecore’s ILayoutServiceValidator implementation:
using System;
using Sitecore;
using Sitecore.Data.Items;
using Sitecore.LayoutService.Mvc.Factories.Interfaces;
using Sitecore.LayoutService.Mvc.Models;
using Sitecore.LayoutService.Mvc.Services.Interfaces;
namespace CoreySmith.Feature.LayoutService.Services
{
public class LayoutNotFoundLayoutServiceValidator : ILayoutServiceValidator
{
private readonly ILayoutServiceValidator _innerValidator;
private readonly IRenderingViewFactory _renderingViewFactory;
public LayoutNotFoundLayoutServiceValidator(ILayoutServiceValidator validator, IRenderingViewFactory renderingViewFactory)
{
_innerValidator = validator ?? throw new ArgumentNullException(nameof(validator));
_renderingViewFactory = renderingViewFactory ?? throw new ArgumentNullException(nameof(renderingViewFactory));
}
public ValidationRequestResult ValidateLayoutServiceRequest(ValidationRequestModel requestModel)
{
var result = _innerValidator.ValidateLayoutServiceRequest(requestModel);
if (result?.HttpStatusCodeResult != null) return result;
var item = result?.ResolvedItem;
if (item == null) return result;
using (new ContextItemSwitcher(item))
{
if (_renderingViewFactory.GetRenderingView() != null)
{
return result;
}
}
result.ResolvedItem = null;
// Clear Context.Item so that site context is not added to Layout Service response
Context.Item = null;
return result;
}
}
}
IRenderingViewFactory.GetRenderingView() is what Headless Services uses to determine if an item has layout. If the resolved item does not have layout, the validator sets result.ResolvedItem to null, the same as for an item that does not exist.
Context.Item must be set to null before returning the result; otherwise, the site context will be added to the Layout Service response like this:
{
"sitecore": {
"context": {
"pageEditing": false,
"site": {
"name": "website"
},
"pageState": "normal",
"language": "en"
},
"route": null
}
}
Decorate Sitecore’s ILayoutServiceValidator with the LayoutNotFoundLayoutServiceValidator. I recommend Scrutor’s Decorate extension for this with Microsoft.Extensions.DependencyInjection:
using CoreySmith.Feature.LayoutService.Services;
using Microsoft.Extensions.DependencyInjection;
using Sitecore.DependencyInjection;
using Sitecore.LayoutService.Mvc.Services.Interfaces;
namespace CoreySmith.Feature.LayoutService
{
public class LayoutServiceConfigurator : IServicesConfigurator
{
public void Configure(IServiceCollection serviceCollection)
{
serviceCollection.Decorate<ILayoutServiceValidator, LayoutNotFoundLayoutServiceValidator>();
}
}
}
And patch it into Sitecore config:
<?xml version="1.0" encoding="utf-8"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
<sitecore>
<services>
<configurator type="CoreySmith.Feature.LayoutService.LayoutServiceConfigurator, CoreySmith.Feature.LayoutService" />
</services>
</sitecore>
</configuration>
Deploy to your Sitecore instance and enjoy.
Sitecore 9.0 Update-2 through Sitecore 10.1
For these versions of Sitecore, you must subclass the LayoutServiceController and override the ValidateLayoutServiceRequest method:
using System.Web.Mvc;
using Sitecore.Data.Items;
using Sitecore.LayoutService.Configuration;
using Sitecore.LayoutService.ItemRendering;
using Sitecore.LayoutService.Mvc.Controllers;
using Sitecore.LayoutService.Mvc.ItemResolving;
using Sitecore.LayoutService.Mvc.Results;
using Sitecore.LayoutService.Serialization;
using Sitecore.Mvc.Presentation;
namespace CoreySmith.Feature.LayoutService.Controllers
{
public class LayoutNotFoundLayoutServiceController : LayoutServiceController
{
public LayoutNotFoundLayoutServiceController(
ILayoutService layoutService,
ISerializerService serializerService,
IConfiguration configuration,
IPlaceholderRenderingService placeholderService,
IItemResolver itemResolver,
ILayoutServiceContext serviceContext,
ILayoutServiceResultFactory layoutServiceResultFactory)
: base(layoutService, serializerService, configuration, placeholderService, itemResolver, serviceContext, layoutServiceResultFactory)
{
}
protected override ActionResult ValidateLayoutServiceRequest(string configuration, string item, out Item resolvedItem, out NamedConfiguration resolvedConfiguration)
{
var result = base.ValidateLayoutServiceRequest(configuration, item, out resolvedItem, out resolvedConfiguration);
if (result != null || resolvedItem == null) return result;
using (new ContextItemSwitcher(resolvedItem))
{
if (PageContext.Current.PageView is RenderingView)
{
return null;
}
}
resolvedItem = null;
// Clear Context.Item so that site context is not added to Layout Service response
Sitecore.Context.Item = null;
return null;
}
}
}
PageContext.Current.PageView is RenderingView is how JSS/Headless Services determines if an item has layout. If the resolved item does not have layout, the override sets resolvedItem to null, the same as for an item that does not exist.
On Sitecore 10.1, Context.Item must be set to null before returning the result; otherwise, the site context will be added to the Layout Service response like this:
{
"sitecore": {
"context": {
"pageEditing": false,
"site": {
"name": "website"
},
"pageState": "normal",
"language": "en"
},
"route": null
}
}
On Sitecore 10.0 Update-3 and earlier, the Layout Service adds the site context for items that don’t exist, so the response will match either way for those versions.
Replace Sitecore’s LayoutServiceController with the new LayoutNotFoundLayoutServiceController:
using CoreySmith.Feature.LayoutService.Controllers;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Sitecore.DependencyInjection;
using Sitecore.LayoutService.Mvc.Controllers;
namespace CoreySmith.Feature.LayoutService
{
public class LayoutServiceConfigurator : IServicesConfigurator
{
public void Configure(IServiceCollection serviceCollection)
{
serviceCollection.Replace(ServiceDescriptor.Transient<LayoutServiceController, LayoutNotFoundLayoutServiceController>());
}
}
}
And patch it into Sitecore config:
<?xml version="1.0" encoding="utf-8"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
<sitecore>
<services>
<configurator type="CoreySmith.Feature.LayoutService.LayoutServiceConfigurator, CoreySmith.Feature.LayoutService" />
</services>
</sitecore>
</configuration>
Deploy to your Sitecore instance and enjoy.
Conclusion
If you’ve been bothered by this issue, please put in a ticket to Sitecore Support asking them to fix it. This is something that we should not need to patch. Sitecore fixed SXA’s Layout Not Found behavior, so there’s hope they will fix it for Headless Services, too.