Editor Friendly 404 Pages in Umbraco

umbraco csharp contentfinder

When a visitor lands on a page that doesn’t exist, a helpful 404 page can make all the difference. But hardcoded error pages are a maintenance burden and don’t give editors the flexibility they need. Let’s fix that by creating editor-friendly 404 pages in Umbraco.

The Site Structure

This approach builds on the “ultimate” site structure pattern, where each site has a root node containing site-wide settings. On this site node, we add a content picker property that lets editors choose which page should be displayed when a 404 occurs.

The property setup is simple:

  • Property name: Not Found Page
  • Alias: notFoundPage
  • Type: Content Picker

This gives editors full control over the 404 experience. They can create a dedicated “Page Not Found” content page with custom messaging, helpful links, search functionality, or whatever else might help visitors find their way.

The ContentLastChanceFinder

Umbraco’s IContentLastChanceFinder is called when no other content finder can match the requested URL. This is our hook for serving the custom 404 page.

The challenge with multi-site setups is figuring out which site’s 404 page to show. A request to /dk/some/nonexistent/page should show the Danish site’s 404, while /en/missing should show the English one.

The solution: find the closest ancestor of the requested URL in the content tree, then traverse up to find the site node and its configured 404 page.

using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Extensions;

public class NotFoundContentFinder : IContentLastChanceFinder
{
    private readonly IUmbracoContextAccessor _umbracoContextAccessor;

    public NotFoundContentFinder(IUmbracoContextAccessor umbracoContextAccessor)
    {
        _umbracoContextAccessor = umbracoContextAccessor;
    }

    public Task<bool> TryFindContent(IPublishedRequestBuilder request)
    {
        if (!_umbracoContextAccessor.TryGetUmbracoContext(out var umbracoContext))
        {
            return Task.FromResult(false);
        }

        var notFoundPage = FindNotFoundPage(request, umbracoContext);

        if (notFoundPage is null)
        {
            return Task.FromResult(false);
        }

        request.SetPublishedContent(notFoundPage);
        request.SetResponseStatus(404);

        return Task.FromResult(true);
    }

    private IPublishedContent? FindNotFoundPage(
        IPublishedRequestBuilder request,
        IUmbracoContext umbracoContext)
    {
        var closestPage = GetClosestPageFromRequest(request, umbracoContext);
        
        if (closestPage is null)
        {
            // Fallback: try to get from the first root node
            var root = umbracoContext.Content?.GetAtRoot().FirstOrDefault();
            return root is not null ? GetNotFoundPageFromAncestors(root) : null;
        }

        // Found the closest ancestor, now find the 404 page
        return GetNotFoundPageFromAncestors(closestPage);
    }

    private IPublishedContent? GetClosestPageFromRequest(IPublishedRequestBuilder request, IUmbracoContext umbracoContext)
    {
        // Try to find the closest ancestor based on the URL segments
        var path = request.AbsolutePathDecoded.Trim('/');
        var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);

        // Start from the full path and work backwards
        for (var i = segments.Length; i >= 0; i--)
        {
            var testPath = "/" + string.Join("/", segments.Take(i));
            var content = umbracoContext.Content?.GetByRoute(testPath);

            if (content is not null)
            {
                return content;
            }
        }

        return null;
    }

    private IPublishedContent? GetNotFoundPageFromAncestors(IPublishedContent content)
    {
        // Traverse up to find a node with the notFoundPage property
        var current = content;

        while (current is not null)
        {
            var notFoundPage = current.Value<IPublishedContent>("notFoundPage");

            if (notFoundPage is not null)
            {
                return notFoundPage;
            }

            current = current.Parent;
        }

        return null;
    }
}

Registering the ContentLastChanceFinder

Register the finder in your composer:

using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.DependencyInjection;

public class NotFoundComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.SetContentLastChanceFinder<NotFoundContentFinder>();
    }
}

How It Works

  1. A visitor requests /products/nonexistent-item
  2. Umbraco’s content finders fail to match the URL
  3. Our NotFoundContentFinder kicks in
  4. It tries /products/nonexistent-item, then /products, then /
  5. When it finds /products exists in the content tree, it has the closest ancestor
  6. It traverses up from /products looking for a notFoundPage property
  7. The site node has the property set, so it returns that page
  8. Umbraco renders the 404 page with a 404 status code

Multi-Site Benefits

This approach shines in multi-site setups:

  • Each site can have its own 404 page - Different branding, language, and content
  • Editors manage everything - No developer intervention needed to update 404 content
  • Automatic site detection - The correct 404 page is served based on where in the content tree the request would have been

Final Thoughts

By combining a simple content picker property with a ContentLastChanceFinder, you give editors full control over the 404 experience while ensuring visitors always see the right page for the site they’re on. It’s a small investment that pays off in maintainability and editor happiness.