A nice error page could turn an otherwise unpleasant user experience into something better, it might even trigger a little laugh. In this article, we're going to explore the tools that .NET 6 and Umbraco 10 have to offer and we'll create an error page that can be edited by content editors with a static fallback if things go really bad.
What we're going to do
Setting up dynamic error pages is not difficult, but it does take a few steps. If you follow along with this tutorial, you will be doing the following:
- Add an exception handler to re-enter the middleware pipeline on error
- Create a content finder to find the error Umbraco page during re-entry
- Create an additional middleware to clean up the context upon re-entry
- Add an additional exception handler middleware that falls back to a static error page if the dynamic error page fails
- Create an additional middleware to ensure that the static error page is served with status code 500
Before we get started
For illustration purposes, we're going to quickly break a page, so we can see how the error middleware performs. I'm going to create a basic website and install the clean starter kit. I'll then hijack the route of the contact page with this controller:
ContactController.cs
public class ContactController : RenderController
{
public ContactController(
ILogger<RenderController> logger,
ICompositeViewEngine compositeViewEngine,
IUmbracoContextAccessor umbracoContextAccessor)
: base(logger, compositeViewEngine, umbracoContextAccessor)
{
}
public override IActionResult Index()
{
throw new Exception("Whoops, something went wrong!");
}
}
Now if we request the contact page, we get the standard (ugly) 500 error page:
Step 1: Add the exception handler
ASP.NET 6 has a dedicated middleware for handling exceptions. It allows you to re-enter the middleware pipeline with a different url, enabling you to serve an alternative page when an error occurs. In our case, it doesn't matter what the url is. Add the handler in your middleware pipeline like this:
Startup.cs
public class Startup
{
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// ... other middlewares
// 👇 Insert this middleware before the Umbraco middlewares.
// The path that you enter here can be anything you want, but it cannot be a path with a file extension.
app.UseExceptionHandler("/error");
app.UseUmbraco()
.WithMiddleware(u =>
{
// ... The rest of the pipeline
}
}
}
Congratulations, you can now re-enter the middleware pipeline after an exception.
Next, we need to tell Umbraco how to find the right content for the error page:
Step 2: Creating a content finder
Umbraco has an extensive routing solution that we can hook into to select the error page to handle errors with. First we'll create a dedicated server error page and then we'll create a content finder that can find it:
I'm going to make a copy of the error document type and call it "Server Error":
I'll then create a new error page in the content tree right below the homepage:
Now that we have some content to show on error, we can create a content finder to find it for us:
ServerErrorContentFinder.cs
public class ServerErrorContentFinder : IContentFinder
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IUmbracoContextAccessor _umbracoContextAccessor;
public ServerErrorContentFinder(IHttpContextAccessor httpContextAccessor, IUmbracoContextAccessor umbracoContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
_umbracoContextAccessor = umbracoContextAccessor;
}
public Task<bool> TryFindContent(IPublishedRequestBuilder request)
{
// 👇 Upon re-entry, the exception middleware adds a feature to the context with the details about the exception.
// This let's us detect if we're handling errors currently or not.
if (_httpContextAccessor.GetRequiredHttpContext().Features.Get<IExceptionHandlerPathFeature>() is null)
{
return Task.FromResult(false);
}
// at this point, we know that we are routing an error page
var rootNode = GetRootNode(request);
// 👇 In our example, the server error page is always below the homepage and of type ServerError,
// but you can use any logic you want to find the error page.
var errorPage = rootNode.FirstChild<ServerError>();
request.SetPublishedContent(errorPage);
return Task.FromResult(true);
}
private IPublishedContent GetRootNode(IPublishedRequestBuilder request)
{
// 👇 Umbraco provides us with the appropriate root node automatically, so we can use that to select the root node.
var umbracoContext = _umbracoContextAccessor.GetRequiredUmbracoContext();
if (request.HasDomain())
{
return umbracoContext.Content!.GetById(request.Domain!.ContentId)!;
}
// 👇 if no domain is assigned, we have to fall back to the first node in the content root
return umbracoContext.Content!.GetAtRoot().First();
}
}
Next we register the content finder in the DI container using a composer:
ServerErrorComposer.cs
public class ServerErrorComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
{
// 👇 Insert the content finder at the start so it has a chance to route the request before other content finders do.
builder.ContentFinders().Insert<ServerErrorContentFinder>(0);
}
}
So was that it? Not quite. We still get the ugly 500 page and you'll notice that the content finder never fires upon error. That's because Umbraco leaves some rubble behind that prevents it from rerunning the routing step. We'll need to clean up the context to make the content finder work.
Step 3: Prepare the context for error handling
Upon routing a request, Umbraco creates an object with the route values that it has calculated. As long as that object exists, Umbraco will refuse to re-route the request. The solution is to simply delete the object upon re-entry.
On top of that, the exception handler middleware changes the path in the request to whatever we entered in step 1. To make our content finder work as intended, we need to temporarily reset that path to the original.
In order to do this, we create the following middleware:
ServerErrorCleanupMiddleware.cs
public class ServerErrorCleanupMiddleware
{
private readonly RequestDelegate _next;
public ServerErrorCleanupMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// 👇 Only run this middleware if we're handling errors
var errorRoutingFeature = context.Features.Get<IExceptionHandlerPathFeature>();
if (errorRoutingFeature is null)
{
await _next(context);
return;
}
// 👇 Delete the Umbraco route values so that umbraco will recalculate them
context.Features.Set<UmbracoRouteValues>(null);
// 👇 If the error path is set to a static file,
// we don't want to apply any further cleanup and should skip the rest of this middleware.
if (context.Request.IsClientSideRequest())
{
await _next(context);
return;
}
// 👇 The error feature contains the original request path.
// Umbraco will automatically calculate the umbraco domain for us if we reset the path
var originalPath = context.Request.Path;
context.Request.Path = errorRoutingFeature.Path;
try
{
await _next(context);
}
finally
{
// 👇 after running the middleware, make sure to restore the path to the error path.
// Otherwise the exception middleware gets stuck in a loop
context.Request.Path = originalPath;
}
}
}
Now register this middleware in the middleware pipeline, right behind the exception handler middleware:
Startup.cs
public class Startup
{
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// ... other middlewares
app.UseExceptionHandler("/error");
app.UseMiddleware<ServerErrorCleanupMiddleware>();
app.UseUmbraco()
.WithMiddleware(u =>
{
// ... The rest of the pipeline
}
}
}
Now we check out the contact page:
It works!! 🎉
Step 4: Add a static fallback error page
But what if the error page fails as well? Let's simulate this by throwing an exception on the error page:
ServerErrorController.cs
public class ServerErrorController : RenderController
{
public ServerErrorController(
ILogger<RenderController> logger,
ICompositeViewEngine compositeViewEngine,
IUmbracoContextAccessor umbracoContextAccessor)
: base(logger, compositeViewEngine, umbracoContextAccessor)
{
}
public override IActionResult Index()
{
throw new Exception("Oof, the error page doesn't work!");
}
}
Now we're back where we started, with an ugly 500 page. To solve this, we'll add another exception handler middleware, right before the first one:
Startup.cs
public class Startup
{
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// ... other middlewares
// 👇 Add another exception handler here that points to a static file
app.UseExceptionHandler("/error.html");
app.UseExceptionHandler("/error");
app.UseMiddleware<ServerErrorCleanupMiddleware>();
app.UseUmbraco()
.WithMiddleware(u =>
{
// ... The rest of the pipeline
}
}
}
We also need to add a static file in our project. We create a simple file error.html
inside wwwroot
:
error.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>500 Server Error</title>
</head>
<body>
<h1>Oops! This page doesn't seem to work right now</h1>
</body>
</html>
If we now attempt to load the contact page, we see this:
Obviously you'd create a more engaging static error page than this one, but you get the point.
It's not quite done yet though, because if we look in the DevTools, we can see that the static error page is served with a 200 OK response.
This is not desirable, because that means that google might index your error page. The error page should be served with a 500 status code, so let's fix that:
Step 5: Ensuring 500 status code on error pages
To ensure the correct status code on error pages, we'll create another middleware:
ServerErrorResponseCodeMiddleware.cs
public class ServerErrorResponseCodeMiddleware
{
private readonly RequestDelegate _next;
public ServerErrorResponseCodeMiddleware(RequestDelegate next)
{
_next = next;
}
public Task InvokeAsync(HttpContext context)
{
// 👇 As soon as we start writing the response to the client,
// we need to check if we're sending an error response.
// If we do: ensure that the statuscode is 500
context.Response.OnStarting(() =>
{
var exceptionPathFeature = context.Features.Get<IExceptionHandlerPathFeature>();
if (exceptionPathFeature is not null)
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
}
return Task.CompletedTask;
});
return _next(context);
}
}
We register this middleware in the pipeline:
Startup.cs
public class Startup
{
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// ... other middlewares
// 👇 Add the middleware right before all other error middlewares
app.UseMiddleware<ServerErrorResponseCodeMiddleware>();
app.UseExceptionHandler("/error.html");
app.UseExceptionHandler("/error");
app.UseMiddleware<ServerErrorCleanupMiddleware>();
app.UseUmbraco()
.WithMiddleware(u =>
{
// ... The rest of the pipeline
}
}
}
Now the error page is always served with the right status code:
Victory!! 🎉
The benefits
If you made it all the way to here, well done! It's been a bit of work, but it's all worth it, because we benefit in several ways:
- ✅ The browser stays on the same URL
- ✅ The browser always receives status code 500 upon failure
- ✅ The error page can be any page you want
- ✅ You can make use of Umbraco route hijacking for your error page
- ✅ The static fallback ensures a nice error page, no matter what
- ✅ It supports multi-site and multi-language, regardless of the domain strategy that you use
Final thoughts
Both ASP.NET 6 and Umbraco 10 offer a great framework for serving dynamic error pages. With a few middlewares, we can turn any page into an error page with pretty much limitless freedom.
Ideally I'd like to see this being turned into a package that allows content editors to select error pages, much like the PageNotFound package, but for now I'm happy to just use this snippet of code in my projects.
That's all I have to say for now. I hope this was helpful and perhaps I'll see you in my next blog! 😊
Top comments (0)