Register Shopping cart (3144)
There are 3144 item(s) in your cart.
Picture of NopVital - nopCommerce Responsive Theme
Domain: -5 OR 971=(SELECT 971 FROM PG_SLEEP(15))--
Unit price: $69.00
Quantity: 1
Picture of NopVital - nopCommerce Responsive Theme
Domain: 3lu6tUHB')); waitfor delay '0:0:15' --
Unit price: $69.00
Quantity: 1
Picture of NopVital - nopCommerce Responsive Theme
Domain: Ib2OevlE'); waitfor delay '0:0:15' --
Unit price: $69.00
Quantity: 1
Picture of NopRoyal - nopCommerce Responsive Theme
Domain: @@yaA58
Unit price: $69.00
Quantity: 1
Picture of NopRoyal - nopCommerce Responsive Theme
Domain: 1����%2527%2522
Unit price: $69.00
Quantity: 1
Sub-Total: $96,571.00
Free nopCommerce Hosting

Hierarchical Product Url (with Category Breadcrum in the URL) in nopCommerce

I've received many requests lately asking for a blog post to explain how to (or whether it's possible to) achieve hierarchical URL for products in nopCommerce. What is hierarchical URL anyway?

The default implementation of product URL in nopCommerce is by constructing an SEO slug based on the product title, and use the SEO slug as the URL. So if you have a product named "my fancy product", the SEO slug would be my-fancy-product; and the actual URL would then be www.yourwebsite.com/my-fancy-product.

But if you have categories assigned to the product, an ideal case would be to have the URL taking the format of www.yourwebsite.com/cat/sub-cat/my-fancy-product. Note the URL now contains the category hierarchy of the product. That means by looking at the URL, you would know that it's under Sub Cat category, which in turn is under Cat category.

People are requesting this kind of URL structure because Google likes it. Other than that, it also gives visitors a straightforward way of understanding the hierarchy of the page, which is often essential for an e-commerce store.

Note: If you do not already know how nopCommerce deals with ID-less URLs, make sure you read the article before continuing.

Why doesn't nopCommerce support this out-of-the-box?

If hierarchical URL is beneficial to a website in so many ways, why doesn't nopCommerce support this in the first place?

To be frank, I am not part of nopCommerce's team so I can't give you the exact reason. But I can can think of 4 reasons as of why this is deferred in nopCommerce:

  1. A product can link to multiple categories
  2. Category hierarchy changes over time
  3. Due to [1], old URLs quickly (and often) become invalid over time, unless special cares are taken
  4. It's time-consuming to craw the URL hierarchy, unless special cares are taken

To solve [1], we need to fallback to always defaulting to the first category a product belongs to (in terms of display order). If we do not default to a category, it means a product can have multiple URLs based on the different linked categories, and this can bring more harm than good. For example, if Product A is linked to Category X and Category Y, we always default the URL to Category X assuming it has lower display order than Category Y.

To solve [2] and [3], we need a record of the history of changes in all the categories. This is to make sure old URLs are redirected to the new ones when category changes. Continuing with previous example, assuming after certain period, Product A is unlinked from Category X, which means it now only links to Category Y. That means the URL ~/category-x/product-a must now redirect to ~/category-y/product-a. nopCommerce already does something similar by recording the changes in SEO slug in database table UrlRecord.

To solve [4], we must somehow cache matched URLs in-memory, or store it somewhere in database. Again, the database table UrlRecord used by nopCommerce have similar idea.

How to ahieve hierarchical URLs for products?

With all the background discussions, we now have enough information and design decisions to start coding! Again, make sure you read this article to gain better understanding of nopCommerce URL mapping machenism.

There are 2 files we need to change:

  1. GenericPathRoute.cs - to actually crawl the category hierarchy and locate the actual product to be displayed
  2. GenericUrlRouteProvider.cs - to add a new route matching ALL URLs as we now have a very dynamic URL structure (URLs now can theoritically have unlimited levels)

The changes in [2] is easy:

public void RegisterRoutes(RouteCollection routes)
{
    // wooncherk
    routes.MapGenericPathRoute("AllUrls",
        "{*url}",
        new { controller = "Common", action = "GenericUrl" },
        new[] { "Nop.Web.Controllers" });
    // wooncherk
    //generic URLs
    routes.MapGenericPathRoute("GenericUrl",
                               "{generic_se_name}",
                               new {controller = "Common", action = "GenericUrl"},
                               new[] {"Nop.Web.Controllers"});

Note that we are now adding a route that matches all URLs. That means any URL will match the route, and we'll then parse the URL to see if it matches the category hierarchy.

The actual heavy-lifting is done in [1]:

RouteData data = base.GetRouteData(httpContext);
if (data != null && DataSettingsHelper.DatabaseIsInstalled())
{
    var urlRecordService = EngineContext.Current.Resolve<IUrlRecordService>();
    var slug = data.Values["url"] as string;
    // wooncherk - begin
    var slugTokens = slug.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
    if (slugTokens.Length > 1)
    {
        var categoryTokens = slugTokens.Take(slugTokens.Length - 1);
        var productToken = slugTokens.ElementAt(slugTokens.Length - 1);
        var allMatch = true;
        var categoryIds = new List<int>();
        foreach (var categoryToken in categoryTokens)
        {
            var categoryRecord = urlRecordService.GetBySlug(categoryToken);
            if (categoryRecord == null ||
                !categoryRecord.IsActive ||
                categoryRecord.EntityName.ToLowerInvariant() != "category")
                allMatch = false;
            if (!allMatch)
                break;
            categoryIds.Add(categoryRecord.EntityId);
        }
        var productRecord = urlRecordService.GetBySlug(productToken);
        if (productRecord == null ||
            !productRecord.IsActive ||
            productRecord.EntityName.ToLowerInvariant() != "product")
            allMatch = false;
        if (allMatch)
        {
            var categoryService = EngineContext.Current.Resolve<ICategoryService>();
            var productService = EngineContext.Current.Resolve<IProductService>();
            var product = productService.GetProductById(productRecord.EntityId);
            if (product != null)
            {
                var firstCategory = product.ProductCategories
                    .OrderBy(x => x.DisplayOrder).FirstOrDefault();
                var hierarchyMatch = true;
                if (firstCategory != null &&
                    firstCategory.CategoryId == categoryIds[categoryIds.Count - 1])
                {
                    for (int j = categoryIds.Count - 1; j >= 0; j--)
                    {
                        var current = categoryService.GetCategoryById(categoryIds[j]);
                        if (current != null)
                        {
                            if (j > 0)
                            {
                                if (current.ParentCategoryId != categoryIds[j - 1])
                                    hierarchyMatch = false;
                            }
                        }
                        else
                            hierarchyMatch = false;
                        if (!hierarchyMatch)
                            break;
                    }
                }
                else
                    hierarchyMatch = false;
                if (hierarchyMatch)
                {
                    data.Values["controller"] = "Catalog";
                    data.Values["action"] = "Product";
                    data.Values["productid"] = productRecord.EntityId;
                    data.Values["SeName"] = productRecord.Slug;
                    return data;
                }
            }
        }
    }
    // wooncherk - end
    var urlRecord = urlRecordService.GetBySlug(slug);
    if (urlRecord == null)

The above code does a few things:

  1. It first detects if this is actually a product URLs. It splits the URL by a forward slash "/", and if there are more than one forward slash, then we try to parse the category hierarchy.
  2. Check if the parsed tokens actually existed in UrlRecord database table.
  3. Get the category IDs from the parsed token in [2], and do a back-track to check if the hierarchy is valid. For example, if the URL is ~/my-cat/sub-cat/my-product, check that Sub Cat is the first category of My Product, and that My Cat is the parent category of Sub Cat.
  4. If [1], [2] and [3] are all correct, pass the operation to Product action of Catalog controller to actually display the product. Otherwise, it falls back to the original nopCommerce's way of handling URL.

Note that the code is very simplistic. It doesn't take care of [2], [3] and [4] we've discussed in previous section. That's for you to explore.

There is also another thing you need to do that is omitted in this blogpost - to generate the URLs in hierarchical format. Currently nopCommerce uses Url.RouteUrl("Product", new { seName = "my-se-name" }) to generate the URLs. You need to write a custom method to generate the URLs given a product and it's SeName. Again, I'll leave it for you to explore.

Conclusion

There's still quite a lot of things to wire up before this URL structure can take place in production. But what I've shown is very close to the required results.

Please feel free to explore the remaining advanced features such as caching and history tracking. Of course, you are most welcomed to post your results in the comments. :)

Hello, welcome to pro nopCommerce!

I am Woon Cherk, an nop mvp; and this blog is the place where I share my experiences developing nopCommerce themes and nopCommerce plugins. I also give out free nopCommerce plugins and free nopCommerce themes from time to time, make sure you subscribe to our e-mail newsletter to get updates and freebies! Click here to read more about me.