---
title: "Editor Friendly 404 Pages in Umbraco"
description: "How to create customizable 404 pages that editors can manage, with support for multi-site setups using a ContentLastChanceFinder."
date: 2026-04-09
tags: ["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](https://cultiv.nl/blog/tip-of-the-week-the-ultimate-site-structure-setup/) 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.

```csharp
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:

```csharp
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.