Umbraco v17 - Custom URL Providers and .GetContentUrlsAsync()

I’m upgrading a website that has a couple bits of custom URL-related functionality from Umbraco 13 to 17.

  • Custom URL provider: A custom URL provider that adds a date string to the URL of Blog Post Page documents (i.e. /blog/a-blog-post to /blog/2025/12/25/a-blog-post.
  • CDN cache invalidation: A notification handler that purges CDN-cached URLs when documents are published. The list of all URLs a page might appear at is built by calling .GetContentUrlsAsync().

In Umbraco 13, this worked as expected. The URL provider would generate custom URLs in the desired format, and .GetContentUrlsAsync() would return a list of the customised URLs.

using System.Globalization;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Navigation;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Web.Common.PublishedModels;

namespace UmbracoSite.UrlProviders
{
	public class BlogPostPageUrlProvider : NewDefaultUrlProvider
	{
		public BlogPostPageUrlProvider(
			IOptionsMonitor<RequestHandlerSettings> requestSettings,
			ILogger<DefaultUrlProvider> logger,
			ISiteDomainMapper siteDomainMapper,
			IUmbracoContextAccessor umbracoContextAccessor,
			UriUtility uriUtility,
			ILocalizationService localizationService,
			IPublishedContentCache publishedContentCache,
			IDomainCache domainCache,
			IIdKeyMap idKeyMap,
			IDocumentUrlService documentUrlService,
			IDocumentNavigationQueryService navigationQueryService,
			IPublishedContentStatusFilteringService publishedContentStatusFilteringService,
			ILanguageService languageService)
			: base(requestSettings, logger, siteDomainMapper, umbracoContextAccessor, uriUtility, localizationService, publishedContentCache, domainCache, idKeyMap, documentUrlService, navigationQueryService, publishedContentStatusFilteringService, languageService)
		{
		}

		public override UrlInfo? GetUrl(IPublishedContent content, UrlMode mode, string? culture, Uri current)
		{
			if (content == null || content is not BlogPostPage)
			{
				return null;
			}

			var baseUrlInfo = base.GetUrl(content, mode, culture, current);

			if (baseUrlInfo is null)
			{
				return null;
			}

			var publishDate = content.Value<DateTime?>("publishDate", culture);

			if (publishDate is null)
			{
				return baseUrlInfo;
			}

			var currentUrl = baseUrlInfo.Text;

			if (!Uri.TryCreate(currentUrl, UriKind.RelativeOrAbsolute, out var uri))
			{
				return baseUrlInfo;
			}

			var schemeHost = string.Empty;
			var path = currentUrl;

			if (uri.IsAbsoluteUri)
			{
				schemeHost = uri.GetLeftPart(UriPartial.Authority);
				path = uri.AbsolutePath;
			}

			var segments = path.TrimEnd('/')
				.Split('/', StringSplitOptions.RemoveEmptyEntries)
				.ToList();

			if (segments.Count == 0)
			{
				return baseUrlInfo;
			}

			segments.Insert(segments.Count - 1, publishDate.Value.ToString("yyyy/MM/dd", CultureInfo.InvariantCulture));

			var newPath = "/" + string.Join("/", segments);
			var finalUrl = (schemeHost + newPath).TrimEnd('/');

			return new UrlInfo(finalUrl, true, culture);
		}
	}
}

As part of upgrading to Umbraco 17, I’ve updated the Custom URL provider to work with the breaking changes introduced to UrlInfo and IUrlProvider (I think…):

using System.Globalization;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Navigation;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Web.Common.PublishedModels;

namespace UmbracoSite.UrlProviders
{
	public class BlogPostPageUrlProvider : NewDefaultUrlProvider
	{
		public BlogPostPageUrlProvider(
			IOptionsMonitor<RequestHandlerSettings> requestSettings,
			ILogger<NewDefaultUrlProvider> logger,
			ISiteDomainMapper siteDomainMapper,
			IUmbracoContextAccessor umbracoContextAccessor,
			UriUtility uriUtility,
			IPublishedContentCache publishedContentCache,
			IDomainCache domainCache,
			IIdKeyMap idKeyMap,
			IDocumentUrlService documentUrlService,
			IDocumentNavigationQueryService navigationQueryService,
			IPublishedContentStatusFilteringService publishedContentStatusFilteringService,
			ILanguageService languageService)
			: base(requestSettings, logger, siteDomainMapper, umbracoContextAccessor, uriUtility, publishedContentCache, domainCache, idKeyMap, documentUrlService, navigationQueryService, publishedContentStatusFilteringService, languageService)
		{
		}

		public override UrlInfo? GetUrl(IPublishedContent content, UrlMode mode, string? culture, Uri current)
		{
			if (content is null || content is not BlogPostPage)
			{
				return null;
			}

			var defaultUrlInfo = base.GetUrl(content, mode, culture, current);

			if (defaultUrlInfo is null)
			{
				return null;
			}

			var publishDate = content.Value<DateTime?>("publishDate", culture);

			if (publishDate is null)
			{
				return defaultUrlInfo;
			}

			var originalUrl = defaultUrlInfo.Url;

			if (originalUrl is null)
			{
				return defaultUrlInfo;
			}

			var originalPath = originalUrl.OriginalString;

			if (originalUrl.IsAbsoluteUri)
			{
				originalPath = originalUrl.AbsolutePath;
			}

			var segments = originalPath.Split("/", StringSplitOptions.RemoveEmptyEntries).ToList();

			segments.Insert(segments.Count - 1, publishDate.Value.ToString("yyyy/MM/dd", CultureInfo.InvariantCulture));

			var customPath = $"/{string.Join("/", segments)}";

			if (!Uri.TryCreate(customPath, UriKind.RelativeOrAbsolute, out var customUrl))
			{
				return defaultUrlInfo;
			}

			return new UrlInfo(customUrl, Alias, culture);
		}
	}
}

Unfortunately, .GetContentUrlsAsync() now returns UrlInfo with a Url value of null. I’ve tried passing the UrlInfo the default Alias as well as specifying a unique custom one, without success.

Has anyone had a chance to play with this yet? What am I missing here? Is inheriting NewDefaultUrlProvider still a valid approach, and if so, do you need to pass the original Alias or a new one? Or is the problem with .GetContentUrlAsync()?

Hey @stephen-sherman

I faced the same issue earlier, and later resolved it by implementing it using IContentFinder.

Hi @ashachaudharygd,

Could you expand on that please?

My understanding is that IContentFinder is for matching a front end request to an Umbraco document, so I wouldn’t have expected it to play much of a role here. My IContentFinder is unchanged since Umbraco 16.

To be clear, the custom URL itself works just fine, it’s attempting to retrieve all of the documents URLs using .GetContentUrlsAsync() that no longer does.

using System.Text.RegularExpressions;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Web.Common.PublishedModels;

namespace UmbracoSite.ContentFinders
{
	public class BlogPostPageContentFinder : IContentFinder
	{
		private static readonly Regex DatePathRegex = new Regex(@"/(?<year>\d{4})/(?<month>\d{2})/(?<day>\d{2})/");

		private readonly IUmbracoContextAccessor _umbracoContextAccessor;
		private readonly IDocumentUrlService _documentUrlService;
		private readonly IPublishedContentCache _publishedContentCache;

		public BlogPostPageContentFinder(IUmbracoContextAccessor umbracoContextAccessor, IDocumentUrlService documentUrlService, IPublishedContentCache publishedContentCache)
		{
			_umbracoContextAccessor = umbracoContextAccessor;
			_documentUrlService = documentUrlService;
			_publishedContentCache = publishedContentCache;
		}

		public Task<bool> TryFindContent(IPublishedRequestBuilder request)
		{

			if (!_umbracoContextAccessor.TryGetUmbracoContext(out var umbracoContext))
			{
				return Task.FromResult(false);
			}

			var requestedPath = request.Uri.GetAbsolutePathDecoded();

			if (!DatePathRegex.IsMatch(requestedPath))
			{
				return Task.FromResult(false);
			}

			var strippedPath = DatePathRegex.Replace(requestedPath, "/");

			if (request.HasDomain() && !string.IsNullOrWhiteSpace(request.Domain?.Uri.AbsolutePath))
			{
				strippedPath = strippedPath.TrimStart(request.Domain.Uri.AbsolutePath);
			}

			var documentKey = _documentUrlService.GetDocumentKeyByRoute(
				strippedPath,
				request.Culture,
				request.Domain?.ContentId,
				false);

			if (!documentKey.HasValue)
			{
				return Task.FromResult(false);
			}

			var content = _publishedContentCache.GetById(documentKey.Value);

			if (content == null || content is not BlogPostPage)
			{
				return Task.FromResult(false);
			}

			var contentDate = content.Value<DateTime?>("publishDate", request.Culture)?.Date;

			var urlDateMatch = DatePathRegex.Match(requestedPath);

			if (!urlDateMatch.Success)
			{
				return Task.FromResult(false);
			}

			var year = int.Parse(urlDateMatch.Groups["year"].Value);
			var month = int.Parse(urlDateMatch.Groups["month"].Value);
			var day = int.Parse(urlDateMatch.Groups["day"].Value);

			var urlDate = new DateTime(year, month, day).Date;

			if (contentDate.HasValue && contentDate.Value != urlDate)
			{
				return Task.FromResult(false);
			}

			request.SetPublishedContent(content);

			return Task.FromResult(true);
		}
	}
}