June 9, 2026

Custom URLs in Umbraco without letting the content tree dictate everything

Using an IUrlProvider and IContentFinder together so Umbraco can publish custom URLs without losing routing symmetry.

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 is good.

Until it is not.

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:

  • 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 /my-custom-url.

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 answers:

What URL should Umbraco output for this content item?

GetRouteForNode answers:

What route should the incoming request match for this content item?

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 important 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 important test is not a unit test of IUrlProvider. It is the round trip:

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

Final thought

Umbraco’s tree-based routing is a good default because it makes the CMS easy to reason about.

But public URLs are part of the product experience, not just a projection of the backoffice tree.

When the two need to diverge, the safest implementation is not to scatter URL rules through templates, controllers and redirects. Put the rule in one place, then connect it to Umbraco through the two extension points that matter:

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

That keeps editors free to organize content in a way that makes sense internally, while the public site gets the URLs it actually needs.