Custom URLs in Umbraco without letting the content tree dictate everything

How to combine IUrlProvider and IContentFinder so Umbraco can publish custom editorial URLs and resolve every incoming route through the same routing rules.

One of the things I like about Umbraco is that the content tree is simple to explain.

Editors understand it quickly: pages live in a hierarchy, and the public URL usually follows that hierarchy.

That default works until the editorial structure and public URL structure need to diverge.

Sometimes the editorial structure and the public URL structure are not the same thing. A page might live under a folder because it is easier to manage there, but the public URL needs to be shorter. A blog post might belong under a blog node in the backoffice, but the public URL should be based only on the post title. A landing page might need a campaign URL that does not mirror the content tree.

In those cases, the content tree should remain an editorial tool. It should not become a prison for public URLs.

This article shows one way to implement custom URLs in Umbraco by combining two extension points from Umbraco’s routing pipeline:

  • an IUrlProvider, which changes the URL Umbraco generates for content;
  • an IContentFinder, which resolves incoming requests back to the right content item.

The key idea is symmetry. If your code generates /my-custom-url, your routing code must also know how to resolve it.

This article focuses on live routing. For migration-era URL handling, I keep redirects separate in the related article on Umbraco SEO infrastructure.

The problem

Imagine this content tree:

Home
  Blog
    Posts
      My first blog post

The default Umbraco URL might be something like:

/blog/posts/my-first-blog-post/

But maybe the desired public URL is:

/my-first-blog-post/

Or maybe an editor wants to override it completely:

/guides/custom-umbraco-routing/

The editorial location is still useful. It helps editors organize content. But it should not always decide the public route.

The shape of the solution

I use one small service as the source of truth:

public interface ICustomUrlManager
{
    UrlInfo? GetUrlForNode(IPublishedContent? content, string? culture);
    string? GetRouteForNode(IPublishedContent? content, string culture);
}

The two methods are intentionally related.

GetUrlForNode decides which URL Umbraco should output for a content item. GetRouteForNode decides which route an incoming request should match.

Keeping those rules in one service avoids the common bug where links render one URL, but requests are resolved by a different set of rules.

The custom URL manager

The manager has two responsibilities:

  1. If the editor entered a custom URL, use it.
  2. If there is no explicit custom URL, apply a content-type-specific fallback.

Here is a simplified version:

public class CustomUrlManager(IUrlUtility urlUtility) : ICustomUrlManager
{
    public UrlInfo? GetUrlForNode(IPublishedContent? content, string? culture)
    {
        if (content is null)
        {
            return null;
        }

        var customUrl = GetCustomUrl(content, culture);

        if (string.IsNullOrEmpty(customUrl))
        {
            return null;
        }

        var urlString = content.Root().Url(culture) + customUrl;
        var uri = Uri.TryCreate(urlString, UriKind.RelativeOrAbsolute, out var parsed)
            ? parsed
            : new Uri(urlString, UriKind.RelativeOrAbsolute);

        return new UrlInfo(uri, "customUrlProvider", culture ?? string.Empty, string.Empty, isExternal: false);
    }

    public string? GetRouteForNode(IPublishedContent? content, string culture)
    {
        if (content is null)
        {
            return null;
        }

        var customUrl = GetCustomUrl(content, culture);

        return !string.IsNullOrEmpty(customUrl)
            ? $"{content.Root().Id}/{customUrl}"
            : null;
    }

    private string GetCustomUrl(IPublishedContent content, string? culture)
    {
        var editorUrl = content.Value<string>("customUrl", culture);

        if (!string.IsNullOrEmpty(editorUrl))
        {
            var cleanedUrl = LooksLikeFilePath(editorUrl)
                ? editorUrl
                : string.Join("/",
                    editorUrl.Split(['/'], StringSplitOptions.RemoveEmptyEntries)
                        .Select(segment => urlUtility.CleanUrl(segment, lowerCase: true)));

            if (!cleanedUrl.StartsWith("/"))
            {
                cleanedUrl = $"/{cleanedUrl}";
            }

            return RemoveLeadingSlash(cleanedUrl);
        }

        switch (content.ContentType.Alias)
        {
            case BlogPost.ModelTypeAlias:
                var title = content.Value<string>("title", culture);
                return RemoveLeadingSlash($"/{urlUtility.CleanUrl(title!, lowerCase: true)}");
        }

        return string.Empty;
    }

    private static bool LooksLikeFilePath(string url)
    {
        return url.Count(character => character == '.') == 1;
    }

    private static string RemoveLeadingSlash(string url)
    {
        return url.StartsWith("/") ? url[1..] : url;
    }
}

The editor-controlled field is:

content.Value<string>("customUrl", culture)

In the real project this property is a SEO/editorial field. Editors can set it when they need a public URL that does not follow the content tree.

If the property is empty, the manager can still apply conventions. For example, blog posts can use a slug generated from their title.

Cleaning the URL

The implementation uses a simple URL cleaner:

public string CleanUrl(string text, bool lowerCase = false)
{
    if (string.IsNullOrEmpty(text))
    {
        return string.Empty;
    }

    if (lowerCase)
    {
        text = text.ToLower();
    }

    var punctuation = new Regex(@"[^\w\s]");
    var accents = new Regex(@"\p{Mn}", RegexOptions.Compiled);
    var multipleSpaces = new Regex(@"\s+");

    return multipleSpaces
        .Replace(
            accents.Replace(punctuation.Replace(text, " ").Normalize(NormalizationForm.FormD), string.Empty)
                .Trim(),
            " ")
        .Replace(' ', '-');
}

So a title like:

Caffè & Crème Brûlée!

becomes:

caffe-creme-brulee

This is not meant to be a universal slug engine. It is a pragmatic normalization step for editorial URLs.

Generating URLs with IUrlProvider

The IUrlProvider is the part that changes what Umbraco outputs when code calls Url() on content.

public class CustomUrlProvider(ICustomUrlManager customUrlManager) : IUrlProvider
{
    public string Alias => "customUrlProvider";

    public UrlInfo? GetUrl(IPublishedContent content, UrlMode mode, string? culture, Uri current)
    {
        return customUrlManager.GetUrlForNode(content, culture);
    }

    public IEnumerable<UrlInfo> GetOtherUrls(int id, Uri current)
    {
        return [];
    }

    public Task<UrlInfo?> GetPreviewUrlAsync(IContent content, string? culture, string? segment)
    {
        return Task.FromResult<UrlInfo?>(null);
    }
}

Returning null is important. It tells Umbraco: this provider has no custom URL for this node, so the normal providers can continue.

That keeps the customization narrow. Only content with a custom rule gets a custom URL.

Resolving requests with IContentFinder

Generating a custom URL is only half the job.

If Umbraco outputs:

/guides/custom-umbraco-routing/

then an incoming request for that URL must resolve to the same content item.

That is the job of an IContentFinder:

public class CustomUrlContentFinder(
    ICustomUrlManager customUrlManager,
    IUmbracoContextAccessor umbracoContextAccessor)
    : IContentFinder
{
    public Task<bool> TryFindContent(IPublishedRequestBuilder request)
    {
        if (!umbracoContextAccessor.TryGetUmbracoContext(out _))
        {
            return Task.FromResult(false);
        }

        var route = request.Domain != null
            ? request.Domain.ContentId +
              DomainUtilities.PathRelativeToDomain(request.Domain.Uri, request.Uri.GetAbsolutePathDecoded())
            : request.Uri.GetAbsolutePathDecoded();

        var node = FindContent(request, route);

        return Task.FromResult(node is not null);
    }

    private IPublishedContent? FindContent(IPublishedRequestBuilder request, string route)
    {
        if (!umbracoContextAccessor.TryGetUmbracoContext(out var umbracoContext))
        {
            return null;
        }

        var culture = request.Culture;
        var segments = route.Split('/', StringSplitOptions.RemoveEmptyEntries);
        var rootIdSegment = segments.FirstOrDefault();

        if (!int.TryParse(rootIdSegment, out var rootId))
        {
            return null;
        }

        var rootNode = umbracoContext.Content?.GetById(rootId);

        var node = rootNode?.DescendantsOrSelf(culture)
            .FirstOrDefault(content => customUrlManager.GetRouteForNode(content, culture!) == route);

        if (node is not null)
        {
            request.SetPublishedContent(node);
        }

        return node;
    }
}

The route format is worth noticing:

$"{content.Root().Id}/{customUrl}"

That matches how Umbraco can route content under a domain root. The root id keeps the lookup scoped to the current site root, which matters on multisite installations or setups with assigned domains.

Registering both extension points

Finally, register both pieces in the Umbraco builder:

var umbracoBuilder = builder.Services.AddUmbraco(builder.Environment, builder.Configuration)
    .AddBackOffice()
    .AddWebsite()
    .AddDeliveryApi()
    .AddComposers();

umbracoBuilder.UrlProviders().Insert<CustomUrlProvider>();
umbracoBuilder.ContentFinders().Insert<CustomUrlContentFinder>();

umbracoBuilder.Build();

And register the shared service:

services.AddTransient<ICustomUrlManager, CustomUrlManager>();

The order matters. Inserting your provider and content finder lets your rules run early enough to handle custom routes before Umbraco falls back to its default behavior.

Why both pieces matter

It is easy to implement only the IUrlProvider and think the job is done.

At that point, links generated by Umbraco look correct:

/my-first-blog-post/

But the request can still 404 if no content finder maps that URL back to the content item.

It is also possible to implement only the IContentFinder.

Then incoming requests work, but Umbraco still generates default tree-based URLs in menus, breadcrumbs, sitemaps and internal links.

That creates a split-brain URL system.

The durable rule is:

One source of truth, two adapters.

CustomUrlManager contains the rule. CustomUrlProvider adapts it to generated URLs. CustomUrlContentFinder adapts it to incoming requests.

Editorial URLs vs redirects

Custom URLs are not the same thing as redirects.

A custom URL says:

This is the canonical public URL for this content.

A redirect says:

This old URL should send users somewhere else.

Both are useful, but they solve different problems.

For example:

  • use a custom URL for a page that should permanently live at a shorter editorial URL;
  • use redirects for legacy URLs after a migration;
  • avoid using redirects as a substitute for a clean routing model.

In my project, custom URLs and legacy redirects are handled separately. That keeps the routing model clear.

Tradeoffs

This approach is intentionally simple, but there are tradeoffs.

The content finder scans descendants under the site root:

rootNode?.DescendantsOrSelf(culture)
    .FirstOrDefault(content => customUrlManager.GetRouteForNode(content, culture!) == route);

For a small or medium editorial site this can be acceptable. For a large site, you would probably want an index, cache or lookup table keyed by custom route.

You also need to decide how to handle duplicate custom URLs.

The implementation above assumes editors will not create duplicates. In a production CMS, I would usually add validation in one of these places:

  • a content validation notification;
  • a scheduled integrity check;
  • an editorial dashboard;
  • a custom property editor that warns before saving duplicates.

The third tradeoff is preview.

The provider above returns null from GetPreviewUrlAsync, falling back to Umbraco’s default preview URL behavior. That was enough for this project. If your editors need preview URLs to reflect custom routes, you can implement that too, but it is a separate decision.

What I would test

At minimum, I would test the URL normalization logic:

" Caffè & Crème Brûlée! " -> "caffe-creme-brulee"

Then I would add integration checks for the routing contract:

  • a content item with customUrl = "/guides/custom-routing" renders that URL from Url();
  • a request to /guides/custom-routing resolves the same content item;
  • a content item without customUrl falls back to the default provider;
  • two cultures can generate different custom URLs if the property varies by culture;
  • duplicate URLs are detected or at least reported.

The most useful test covers the entire round trip:

content item -> generated URL -> HTTP request -> same content item

Keep the Rule in One Place

Umbraco’s tree-based routing is a good default, but public URLs sometimes need to diverge from the backoffice tree. Keep the rule in one place and connect it to Umbraco through two extension points:

  • IUrlProvider for URLs going out;
  • IContentFinder for requests coming in.

Editors can then organize content for the backoffice while the public site keeps stable, deliberate URLs.

References

Share

Pass it along

LinkedInFacebookTelegramWhatsAppEmail