Sitecore Bucketed URLs Are a Thing of the Past, With the Custom Link Provider

If you've ever seen a bucketed URL, you'd know it. The path to the item will have authored pages that are SEO friendly, but somewhere along the way you'll see numerical folders that represent the date and time the page was created, like this:

https://www.your-domain.com/blog/2023/05/04/20/21/a-new-way-to-use-urls

These dated folder are there so you can create a large number of children under a single parent, since there's a direct limit of 100. Some organization may prefer this depending on the use case, but I personally think it's an eyesore. Taking the example above, you can have a URL like this:

https://www.your-domain.com/blog/a-new-way-to-use-urls

We're not breaking any parent-child limitation or best practices in Sitecore, since the item is still under the set of dated folders. What happens is we add a Custom Link Provider, which resolves the path to the item (page), omitting the date folders by knowing their parent is a bucketed folder. 


Overriding GetItemUrl

The method behind this change is to identify items in the site's content structure that are flagged as navigable. This means all items you want to directly link to would inherit a base template (in this case, “_Navigable”). There is also an extension the examples below called “IsDerived”, but any simple method to check the item is derived from the based template would be fine. 

The following configuration file will provide the necessary values needed for our new GetItemUrl method. A multisite sample is used to give you an idea for extending it:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform"></configuration>  
  <sitecore>
    <linkManager defaultProvider="sitecore">
      <providers>
        <clear />
        <add name="sitecore" type="MySite.Pipelines.CustomLinkProvider, MySite"
             addAspxExtension="false"
             alwaysIncludeServerUrl="false"
             encodeNames="true"
             languageEmbedding="always"
             languageLocation="filePath"
             lowercaseUrls="true"
             shortenUrls="true"
             useDisplayName="true"
             patch:instead="add[@name='sitecore']">
          <databases hint="list">
            <database>master</database>
            <database>web</database>
          </databases>
          <sites hint="list">
            <site>website</site>
            <site>website2</site>
          </sites>
          <roots hint="list">
            <root>/sitecore/content/website/home/</root>
            <root>/sitecore/content/website2/home/</root>
          </roots>
        </add>
      </providers>
    </linkManager>
  </sitecore>
</configuration>

You can see some standard configuration values here, but some new values like databases, sites and roots are added as well, which will be explored below.


The CustomLinkProvider in Action

The link provider below is a lot simpler than it looks. It will first check to see if it should run, or let base.GetItemUrl take over. As I said earlier, if the item passes this check, then it will run:

item.IsDerived(Templates._Navigable.ID)

A new UriBuilder is created at this point, and eventually the GetPath method is called into action. Here, the lists pathItems and pathParts are created, and only items found to be inheriting the _Navigable base template are included.

  • pathItems = "sitecore, content, website, home, blog, 2023, 05, 04, 20, 21, a-new-way-to-use-urls"
  • pathParts = "blog, a-new-way-to-use-urls"

The partParts list is used with the rest of the earlier built URL to deliver the final result. Here's the full method: 

namespace MySite.Pipelines
{
    public class CustomLinkProvider : LinkProvider
    {
        private readonly Regex HttpEx = new Regex("^https?$", RegexOptions.IgnoreCase);
        public List<string> Sites { get; } = new List<string>();
        public List<string> Roots { get; } = new List<string>();
        public List<string> Databases { get; } = new List<string>();
        public override string GetItemUrl(Item item, UrlOptions options)
        {
            var logPrefix = $"[{typeof(CustomLinkProvider).FullName}.{nameof(GetItemUrl)}()]: ";
            if (item == null)
                return string.Empty;
            var site = options.Site ?? Context.Site;
            if (!Databases.Contains(item.Database?.Name, StringComparer.InvariantCultureIgnoreCase))
                Log.Debug(logPrefix + $"'{item.Paths.FullPath}' is in a database ({item.Database?.Name ?? "null"}) that isn't included.");
            else if (!Sites.Contains(site?.Name, StringComparer.InvariantCultureIgnoreCase))
                Log.Debug(logPrefix + $"'{item.Paths.FullPath}' is not in an included site ({site?.Name ?? "null"}).");
            else if (!Roots.Any(root => item.Paths.FullPath.StartsWith(root, StringComparison.InvariantCultureIgnoreCase)))
                Log.Debug(logPrefix + $"'{item.Paths.FullPath}' is not a descendant of an included root.");
            else if (options.Language != item.Language)
            {
                using (new LanguageSwitcher(options.Language))
                {
                    return GetItemUrl(item, options);
                }
            }
            else if (item.IsDerived(Templates._Link.ID))
            {
                return item.GetLinkUrl(Templates._Link.Fields.Link);
            }
            else if (item.IsDerived(Templates._Navigable.ID))
            {
                var ub = new UriBuilder();
                if (options.LanguageEmbedding == LanguageEmbedding.Always ||
                    (options.LanguageEmbedding == LanguageEmbedding.AsNeeded && options.Language != Context.Language))
                {
                    switch (options.LanguageLocation)
                    {
                        case LanguageLocation.FilePath:
                            ub.Path = $"/{(options.Language ?? Context.Language).Name}";
                            break;
                        case LanguageLocation.QueryString:
                            ub.Query = $"sc_lang={(options.Language ?? Context.Language).Name}";
                            break;
                    }
                }
                ub.Path += GetPath(item, options);
                if (options.AddAspxExtension)
                    ub.Path += ".aspx";
                if (options.AlwaysIncludeServerUrl)
                {
                    ub.Host = SiteDefinitionsProvider.GetHostName(site.SiteInfo);
                    if (!string.IsNullOrEmpty(ub.Host))
                    {
                        ub.Scheme = site?.SiteInfo.Scheme ?? "http";
                        if (!HttpEx.IsMatch(ub.Scheme))
                            ub.Scheme = "http";
                    }
                    var url = options.LowercaseUrls ? ub.ToString().ToLowerInvariant() : ub.ToString();
                    return url;
                }
                else
                {
                    ub.Host = string.Empty;
                    ub.Scheme = string.Empty;
                    var url = (options.LowercaseUrls ? ub.ToString().ToLowerInvariant() : ub.ToString());
                    return url;
                }
            }
            return base.GetItemUrl(item, options);
        }
        public string GetPath(Item item, UrlOptions options = null)
        {
            if (options == null)
                return GetPath(item, UrlOptions.DefaultOptions);
            var pathItems = item.Axes.GetAncestors().Union(new[] { item });
            var pathParts = new List<string>();
            foreach (var pathItem in pathItems)
            {
                if (pathItem.IsDerived(Templates._NavigationRoot.ID))
                    continue;
                if (Roots.Any(root => pathItem.Paths.FullPath.StartsWith(root, StringComparison.InvariantCultureIgnoreCase)) &&
                    pathItem.IsDerived(Templates._Navigable.ID))
                {
                    var pathPart = pathItem.Name;
                    if (options.UseDisplayName && !string.IsNullOrWhiteSpace(pathItem.DisplayName))
                    {
                        pathPart = pathItem.DisplayName;
                    }
                    if (options.EncodeNames)
                    {
                        pathPart = MainUtil.EncodeName(pathPart);
                    }
                    pathParts.Add(pathPart);
                }
            }
            return $"/{string.Join("/", pathParts)}";
        }
    }
}


Just incase you want to use the extension, here's an example of IsDerived:

internal static bool IsDerived(this Item item, Item templateItem)
{
  if (item == null || templateItem == null)
    return false;
  var itemTemplate = TemplateManager.GetTemplate(item);
  return itemTemplate != null && (itemTemplate.ID == templateItem.ID || itemTemplate.DescendsFrom(templateItem.ID));
}


Message me if you have any question about this method, and watch for part 2, the Custom URL Resolver!