Configuring and Customising SEO Friendly URLs in Sitecore Commerce SXA Storefront

In this article, we will look at the configuration and customisation options available for manipulating URLs in the Sitecore Commerce Storefront.

The goal will be to determine how to manipulate the URLs so that they are more SEO-friendly as represented by the following URL structures.

  • https://{domain}/category/{1st level category name}
  • https://{domain}/category/{1st level category name}/{2nd level category name}
  • https://{domain}/category/{1st level category name}/{2nd level category name}/{3rd level category name}
  • https://{domain}/product/{product id}

This would translate to the following examples

  • https://sxa.storefront.com/category/computers-and-tablets
  • https://sxa.storefront.com/category/computers-and-tablets/kids-tablets
  • https://sxa.storefront.com/category/computers-and-tablets/kids-tablets/boys-tablets
  • https://sxa.storefront.com/product/6042221

Throughout this article we will utilise the category Computers and Tablets > Kid’s Tablets and product Minnow Kid’s Tablet—7”, 8GB to review our progress. These examples also contain some non-alphanumeric characters to ensure we take these special characters into consideration.

How Storefront URLs are Generated

Storefront URLs are constructed using the configuration of the site' s linkManager. The configuration is located at sitecore/linkManager/providers/add[@name='commerce'].

Configuration Properties

An important note about the provider configuration properties is that only 3 properties actually affect the generated URLs - includeFriendlyName, useShopLinks, and encodeNames.

  • includeFriendlyName: Includes the DisplayName of the category or product in the URL segment. i.e. {category DisplayName}={category FriendlyId} and {product DisplayName}={ProductId/FriendlyId}.
  • useShopLinks: Constructs URL with shop/{category}/{product} if enabled, otherwise as category/{category} and product/{product} for category and product URLs respectively.
  • includeCatalog: Not currently supported
  • addAspxExtension: N/A
  • alwaysIncludeServerUrl: N/A
  • encodeNames: Encodes the DisplayName portion of the category and product segments. Only supported when useShopLinks is true.
  • languageEmbedding: N/A
  • languageLocation: N/A
  • lowercaseUrls: Not currently supported
  • shortenUrls: Not currently supported
  • useDisplayName: Not currently supported

URLs Generated from Various Configurations

The following decision table shows the available configurations

 Rules
Conditions12345
useShopLinksYYYNN
includeFriendlyNameYYNYN
encodeNamesYN Y 
Actions12345
ShopXXX  
Product/Category   XX
Display Name prefixXX X 
Display Name encodingX  X 

The following table shows the URLs generated from the rules in the above table.

#PageURL
1Categoryhttps://sxa.storefront.com/shop/Kid%E2%80%99sTablets%3dhabitat_master-kid%20s%20tablets
 Producthttps://sxa.storefront.com/shop/Kid%E2%80%99sTablets%3dhabitat_master-kid%20s%20tablets/MinnowKid%E2%80%99sTablet%E2%80%947%E2%80%9D%2C8GB%3d6042221
2Categoryhttps://sxa.storefront.com/shop/Kid’sTablets%3dhabitat_master-kid%20s%20tablets
 Producthttps://sxa.storefront.com/shop/Kid’sTablets%3dhabitat_master-kid%20s%20tablets/MinnowKid’sTablet—7”%2C8GB%3d6042221
3Categoryhttps://sxa.storefront.com/shop/habitat_master-kid%20s%20tablets
 Producthttps://sxa.storefront.com/shop/habitat_master-kid%20s%20tablets/6042221
4Categoryhttps://sxa.storefront.com/category/Kid’sTablets%3dhabitat_master-kid%20s%20tablets
 Producthttps://sxa.storefront.com/product/MinnowKid’sTablet—7”%2C8GB%3d6042221
5Categoryhttps://sxa.storefront.com/category/habitat_master-kid%20s%20tablets
 Producthttps://sxa.storefront.com/product/6042221

In reviewing the results of the configurations above, rule set 5 creates the desired product URL structure we set out to accomplish.

Now we will focus on customising the website solution to generate our catalog URL structure.

Customising the Solution to Complete the SEO-Friendly URLs

Updating the CatalogUrlManager

We will need to override the BuildCategoryLink method of the CatalogUrlManager class to apply the following customisations:-

  • Generate the category hierarchy (or breadcrumb)
  • Remove the catalog name from the category's Friendly Id.
public override string BuildCategoryLink(Item item, bool includeCatalog, bool includeFriendlyName)
{
    return BuildBreadcrumbCategoryUrl(item, includeCatalog, includeFriendlyName, CatalogFoundationConstants.Routes.CategoryUrlRoute);
}

protected virtual string BuildBreadcrumbCategoryUrl(Item item, bool includeCatalog, bool includeFriendlyName, string root)
{
    Assert.ArgumentNotNull(item, nameof(item));

    string catalogName = ExtractCatalogName(item, includeCatalog);
    var categoryBreadcrumbList = GetCategoryBreadcrumbList(item);

    return BuildBreadcrumbCategoryUrl(categoryBreadcrumbList, includeFriendlyName, catalogName, root);
}

protected virtual string BuildBreadcrumbCategoryUrl(List<Item> categories, bool includeFriendlyName, string catalogName, string root)
{
    Assert.ArgumentNotNull(categories, nameof(categories));

    var stringBuilder = new StringBuilder("/");
    if (IncludeLanguage)
    {
        stringBuilder.Append(Context.Language.Name);
        stringBuilder.Append("/");
    }

    if (!string.IsNullOrEmpty(catalogName))
    {
        stringBuilder.Append(EncodeUrlToken(catalogName, true));
        stringBuilder.Append("/");
    }
    stringBuilder.Append(root);

    var itemName = string.Empty;
    var itemFriendlyName = string.Empty;
    foreach (var category in categories)
    {
        stringBuilder.Append("/");
        ExtractCatalogItemInfo(category, includeFriendlyName, out itemName, out itemFriendlyName);
        if (!string.IsNullOrEmpty(itemFriendlyName))
        {
            stringBuilder.Append(EncodeUrlToken(itemFriendlyName, true));
            stringBuilder.Append(UrlTokenDelimiterEncoded);
        }

        itemName = RemoveCatalogFromItemName(root, itemName);
        stringBuilder.Append(EncodeUrlToken(itemName, false));
    }

    return StorefrontContext.StorefrontUri(stringBuilder.ToString()).Path;
}

protected virtual List<Item> GetCategoryBreadcrumbList(Item item)
{
    var categoryBreadcrumbList = new List<Item>();
    var startNavigationCategoryID = StorefrontContext.CurrentStorefront.GetStartNavigationCategory();

    while (item.ID != startNavigationCategoryID)
    {
        categoryBreadcrumbList.Add(item);
        item = item.Parent;
    }
    categoryBreadcrumbList.Reverse();

    return categoryBreadcrumbList;
}

protected virtual string RemoveCatalogFromItemName(string root, string itemName)
{
    if (root == CatalogFoundationConstants.Routes.CategoryUrlRoute)
    {
        var tokens = itemName.Split('-');
        if (tokens.Length > 1)
        {
            itemName = tokens[1];
        }
    }

    return itemName;
}

protected override void ExtractCatalogItemInfo(Item item, bool includeFriendlyName, out string itemName, out string itemFriendlyName)
{
    base.ExtractCatalogItemInfo(item, includeFriendlyName, out itemName, out itemFriendlyName);
    var parentItemName = item.Parent.Name.ToLowerInvariant();
    if (itemName.StartsWith(parentItemName))
    {
        itemName = itemName.Substring(parentItemName.Length);
    }
}

Category URL: https://sxa.storefront.com/category/computers%20and%20tablets/kid%20s%20tablets

The URLs generated are looking good. We do have an issue with the URLs still containing encoded spaces. To control character encoding there is a configuration in the content editor at /sitecore/Commerce/Commerce Control Panel/Storefront Settings/Global Configuration > URL Encoding > Catalog Item Encoding.

Unfortunately there is a quirk in which it doesn't accept space entries, so we will override the EncodeUrlToken and DecodeUrlToken methods instead. As we aren't allowed to have hyphens in the category names, we won't have any character conflicts where encoding or decoding.

protected override string EncodeUrlToken(string urlToken, bool removeInvalidPathCharacters)
{
    if (!string.IsNullOrEmpty(urlToken))
    {
        if (removeInvalidPathCharacters)
        {
            foreach (string invalidPathCharacter in _invalidPathCharacters)
            {
                urlToken = urlToken.Replace(invalidPathCharacter, string.Empty);
            }
        }
        EncodingTokenList.ForEach(t => urlToken = urlToken.Replace(t.Delimiter, t.EncodedDelimiter));
        urlToken = urlToken.Replace(' ', '-');
        urlToken = Uri.EscapeDataString(urlToken).Replace(UrlTokenDelimiter, EncodedDelimiter);
    }

    return urlToken;
}

protected override string DecodeUrlToken(string urlToken)
{
    if (!string.IsNullOrEmpty(urlToken))
    {
        urlToken = Uri.UnescapeDataString(urlToken).Replace(EncodedDelimiter, UrlTokenDelimiter);
        urlToken = urlToken.Replace('-', ' ');
        EncodingTokenList.ForEach(t => urlToken = urlToken.Replace(t.EncodedDelimiter, t.Delimiter));
    }

    return urlToken;
}

Category URL: https://sxa.storefront.com/category/computers-and-tablets/kid-s-tablets

Now that we have our category URL structure that meets our requirements, our last step is to ensure the URLs are resolving back to their correct Sitecore items.

Updating the CatalogPageItemResolver

Now we have covered the URL generation implementation, we now need to resolve these URLs back to their correct Sitecore items.

public override void Process(PipelineArgs args)
{
	if (Context.Item == null || SiteContext.CurrentCatalogItem != null)
	{
		return;
	}

	var contextItemType = GetContextItemType();
	switch (contextItemType)
	{
		case ItemTypes.Category:
		case ItemTypes.Product:
			var isProduct = contextItemType == ItemTypes.Product;
			var catalogItemIdFromUrl = GetCatalogItemIdFromUrl(isProduct);
			if (string.IsNullOrEmpty(catalogItemIdFromUrl))
			{
				break;
			}
			var catalog = StorefrontContext.CurrentStorefront.Catalog;
			var catalogItem = ResolveCatalogItem(catalogItemIdFromUrl, catalog, isProduct);
			if (catalogItem == null && !isProduct)
			{
				catalogItemIdFromUrl = GetCatalogItemIdFromUrl(true);
				if (string.IsNullOrEmpty(catalogItemIdFromUrl))
				{
					break;
				}
				catalogItem = ResolveCatalogItem(catalogItemIdFromUrl, catalog, isProduct);
			}
			if (catalogItem == null)
			{
				WebUtil.Redirect("~/");
			}

			SiteContext.CurrentCatalogItem = catalogItem;
		break;
	}
}

private string GetCatalogItemIdFromUrl(bool isProduct)
{
    var catalogItemId = string.Empty;
    var rawUrl = HttpContext.Current.Request.RawUrl;
    var urlTokens = rawUrl.Split('/');
    if (urlTokens.Any())
    {
        var item = urlTokens.Last();
        var queryStringPosition = item.IndexOf("?", StringComparison.OrdinalIgnoreCase);
        if (queryStringPosition > 0)
        {
            item = item.Substring(0, queryStringPosition);
        }

        if (isProduct && urlTokens.Length >= 4)
        {
            var parentCategoryName = urlTokens[urlTokens.Length - 2];
            item = $"{parentCategoryName}{item}";
        }
        catalogItemId = CatalogUrlManager.ExtractItemId(item);
    }

    return catalogItemId;
}

Summary

We learnt that the construction of the URL can be managed via Sitecore Configuration, the Sitecore Content Editor and via code customisations, depending on the URL requirements.

Source Code: Ajsuth.Foundation.Catalog

Managing Commerce Configuration to Align with Helix Principles

In this article, we will review a sample configuration of the layers.config file to better manage your solution's configuration files when working with Sitecore Experience Commerce 9 (XC9).

If you take a look at an XC9 website's /App_Config/Include folder, you would have noticed that there's still some chaos happening with folder and file prefixes of 'Y' and 'Z' to assign priority order to the patch configuration files. On top of this, you may have even previous worked on projects where there have been crazy levels of prefixing 'z', 'zz', 'zzzzz', etc. to folders and files (I know I have seen some crazy levels of this), leading to developers pulling their hair out trying to locate the file's origin. As chaotic as this is, we will look at how we can bring back order for our solution.

Configuring layers.config

Our intention is to ensure that our solution's patch config files are always in a position to override the platform's patch files, while adhering to the Helix layers principle.

With the following approach, the only additional consideration introduced is to review the platform's custom config folders and files during any future upgrades to update the layers.config file to ensure the intended load order remains in tact.

Note: The following way is just one of many that can be implemented to achieve the same outcome. There is no one right way, so if you manage your solution's configuration files differently there is no need to align to this.

  1. Backup the layers.config. Always important when you plan on modifying any files that are not part of your project solution (as infrequent as this should be).
  2. Copy the layers.config file out of the App_Config folder into a core project in the same location. This is because the layers.config file itself cannot be patched and we would want this file version controlled so that the file can be deployed to all environments, and modified (if required) by our colleagues.
  3. The layers.config file is then updated to specify the load order for all current folders. While this step is a bit of a pain, as any omissions will mean that any folder/files unspecified will be applied after the loadOrder , it allows us to ensure that all of our solution configuration files will be applied last, having the highest level of priority.
    <layer name="Custom" includeFolder="/App_Config/Include/">
      <loadOrder>
        <add path="Cognifide.PowerShell.config" type="File" />
        <add path="Sitecore.Commerce.Carts.config" type="File" />
        <add path="Sitecore.Commerce.Catalogs.config" type="File" />
        <add path="Sitecore.Commerce.config" type="File" />
        <add path="Sitecore.Commerce.Customers.config" type="File" />
        <add path="Sitecore.Commerce.GiftCards.config" type="File" />
        <add path="Sitecore.Commerce.Globalization.config" type="File" />
        <add path="Sitecore.Commerce.Inventory.config" type="File" />
        <add path="Sitecore.Commerce.LoyaltyPrograms.config" type="File" />
        <add path="Sitecore.Commerce.Orders.config" type="File" />
        <add path="Sitecore.Commerce.Payments.config" type="File" />
        <add path="Sitecore.Commerce.Prices.config" type="File" />
        <add path="Sitecore.Commerce.Shipping.config" type="File" />
        <add path="Sitecore.Commerce.WishLists.config" type="File" />
        <add path="z.Cognifide.PowerShell.config" type="File" />
        <add path="ContentTesting" type="Folder" />
        <add path="Examples" type="Folder" />
        <add path="Feature" type="Folder" />
        <add path="Foundation" type="Folder" />
        <add path="Foundation.Overrides" type="Folder" />
        <add path="Project" type="Folder" />
        <add path="Y.Commerce.Engine" type="Folder" />
        <add path="Z.Commerce.Engine" type="Folder" />
        <add path="z.Feature.Overrides" type="Folder" />
        <add path="Z.Foundation" type="Folder" />
        <add path="Z.Foundation.Overrides" type="Folder" />
        <add path="Z.LayoutService" type="Folder" />
      </loadOrder>
    </layer>
  4. We then create three folders, following the Helix principles for our project solution. I just chose to use 'Helix' as a prefix to drill the point in.
    1. Helix.Feature
    2. Helix.Foundation
    3. Helix.Project
  5. Add the new folders to the <loadOrder>.
    <layer name="Custom" includeFolder="/App_Config/Include/">
      <loadOrder>
        ...
        <add path="Z.LayoutService" type="Folder" />
        <add path="Helix.Foundation" type="Folder" />
        <add path="Helix.Feature" type="Folder" />
        <add path="Helix.Project" type="Folder" />
      </loadOrder>
    </layer>
  6. As you are developing your solution you may find that you need to set the load order for the individual configuration files within a certain layer. In this case, you can list out the file order required. In the sample below, we have Feature A, Feature B, and Feature C. Feature A has a configuration conflict with Feature B and needs higher priority. Feature C has no conflicts. We only need to specify the config for Feature A after the folder to ensure the correct patch order.
  7. <layer name="Custom" includeFolder="/App_Config/Include/">
      <loadOrder>
        ...
        <add path="Helix.Feature" type="Folder" />
        <add path="Helix.Feature/Feature.A.config" type="File" />
        <add path="Helix.Project" type="Folder" />
      </loadOrder>
    </layer>
  8. Deploy your solution.

Summary

While you could continue down the path of having project with 'z', 'zz', 'zzzzz', etc. prefixes, by taking the initial steps to isolate and manage your solution's patch files under managed folder and file load order, we bring the chaos back to order with meaningfully named patch files that can be found exactly where you expect them to be located.