Shopify Breadcrumbs Based on Menu Structure (Improved Version)

A couple of years ago, I wrote about how to create breadcrumbs for products that would mirror the main menu of a Shopify site. In reality, that was a quick and dirty method that I had to quickly update a month later, but never got around to writing up the better version.

As I noted there, the design goals were

  • Easy maintenance for shopkeepers. Shopkeepers just have to edit their navigation as normal and breadcrumbs happen automatically.
  • Intuitive for customers. Breadcrumbs match the site nav. Simple
  • Decent abstraction and minimal coupling. We don’t achieve this by asking shopkeepers to mix product types and collections and tags in some simulated hierarchy prone to breaking.

The original version was the quick route, but it had some unnecessary deficits when it came to the last point. The main problem was that an item had to belong to a top-level navigation item (i.e. collection) in order to get a proper breadcrumb. That violates the basic principle of being flexible in the data you accept and strict in the data you output.

The reason for this shortcut was because data structures that one would take for granted in any proper programming language, are not available in a simple templating language like Liquid. This makes the whole thing super cumbersome.

What’s New in Better Breadcrumbs?

In the original version, I created an array of all collections the item belonged to, then compared that with all the top-level navigation links until I found a match.

It quickly became obvious that a real shop commonly does not meet that requirement. Items may only appear in, say, the third level of nav and not belong to any collection.

So the improved method does the following:

  • Scans the entire menu hierarchy and identifies all child menus and all links in all child menus.
  • For any given URL (product, collection, page), we do a lookup to see which menu it appears in. If the product does not appear directly, we look to see if a collection that contains the product is linked in a menu.
  • Generate the breadcrumb based on what we found.

Limited to Three Levels.

We are limited to three levels. This is a basic Shopify limitation, so we couldn’t go deeper if we wanted to.

However, given how cumbersome this is, even if Shopify changes, we wouldn’t want to go deeper. We lack basic data structures and have no ability to do recursion in Liquid, so we need to hardcode the number of levels.

Frankly, navigation that goes deeper than three levels probably needs rethinking anyway (search, filters, faceted search, etc would all be better options).

Note also that Shopify has since added some enhancements to Liquid, in particular the linklist.levels attribute. That would have been a huge help if it had existed when I wrote this code originally, but I don’t think it’s worth rewriting it. But it could probably be simpler if it took advantage of these.

A few resources

The Code

I’m just going to dump this all in one big field so it’s easier to copy and paste. I have pretty extensive comments within the code. If that’s not enough, write to me and I’ll add more comments.

In addition, I’ve added it as a github repo, so you can download it that way.

<!-- /snippets/breadcrumb.liquid -->
{%- comment -%}
  Documentation - http://docs.shopify.com/support/your-website/navigation/creating-a-breadcrumb-navigation
{%- endcomment -%}
{%- unless template == 'index' or template == 'cart' -%}
<nav class="breadcrumb" role="navigation" aria-label="breadcrumbs">
  <a href="/" title="{{ 'general.breadcrumbs.home_link_title' | t }}">{{ 'general.breadcrumbs.home' | t }}</a>

      {%comment%} DETERMINE OBJECT TYPE - product, collection or page   {%endcomment%}
  
{%- if template contains 'product' -%}
  {%- capture product_url_string -%}{%- for collection in product.collections -%}{{collection.url }}|{%- endfor -%}{%- endcapture -%}
  {%- assign object_url_string = product_url_string | append: product.url -%}
{%- elsif template contains 'collection' and collection.handle -%}
  {%- capture object_url_string -%}/collections/{{ collection.handle }}{%- endcapture -%}
{%- elsif template contains 'page' -%}
  {%- capture object_url_string -%}/pages/{{ page.url }}{%- endcapture -%}
{% endif %}

{%- assign object_urls = object_url_string | split: '|' -%}

  {%comment%} 
  GET TITLES OF ALL LINKLISTS
  Linklist is the Shopify term for a menu or submenu. We grab all the titles so we can match against them and
  find out if they are linked from a parent menu.
  {%endcomment%}    
{%- capture linklist_titles_str -%}{%- for linklist in linklists -%}{{ linklist.title | handleize }}|{%- endfor -%}{%- endcapture -%}
{%- assign str_size = linklist_titles_str | size | minus: 1 -%}
{%- assign linklist_titles_str = linklist_titles_str | slice: 0, str_size -%}
{%- assign linklist_titles = linklist_titles_str | split: '|' -%}

  {%comment%} 
  GET TOP LEVEL MENU THEN DRILL DOWN
  First we grab all the top-level menu items and we create a set of strings for each property, which 
  we will eventually split into arrays. We cannot create multi-dimensional arrays in Liquid, but we 
  can create parallel arrays so that if we know the index in the handle array, we can find it's title
  by iterating through the title array until we hit the same index.
  
  If the link handle matches a string in the linklist_titles, the list with that title is the child.
  You might think we would match handles to handles, but we don't. Shopify creates nested menus based
  on the parent item having the exact same label as the child item and converts that to a handle.
  {%endcomment%}

{%- assign level = 1 -%}
{%- for link in linklists.main-menu.links -%}
  {%- assign link_handle = link.title | handle -%}
  {%- assign link_titles = link_titles | append: link.title | append: '|' -%}
  {%- assign link_urls = link_urls | append: link.url | append: '|' -%}
  {%- assign link_levels = link_levels | append: level | append: '|'  -%}
  {%- assign link_parents = link_parents | append: 'main-menu' | append: '|'  -%}
  {%- assign link_handles = link_handles | append: link_handle | append: '|' -%}

  {%- if linklist_titles contains link_handle -%}

      {%comment%} GET CHILDREN    {%endcomment%}

    {%- for clink in linklists[link_handle].links -%}
      {%- if forloop.first == true -%}
        {%- assign level = level | plus: 1 -%}
      {%- endif -%}
      {% assign clink_handle = clink.title | handle %}
      {%- assign link_titles = link_titles | append: clink.title | append: '|' -%}
      {%- assign link_urls = link_urls | append: clink.url | append: '|' -%}
      {%- assign link_levels = link_levels | append: level | append: '|'  -%}
      {%- assign link_parents = link_parents | append: link_handle | append: '|'  -%}
      {%- assign handle = link.title | handleize -%} 
      {%- assign link_handles = link_handles | append: clink_handle | append: '|' -%}

      {%- if linklist_titles contains clink_handle -%}

              {%comment%} GET GRANDCHILDREN  {%endcomment%}

            {%- for gclink in linklists[clink_handle].links -%}
              {%- if forloop.first == true -%}
                {%- assign level = level | plus: 1 -%}
              {%- endif -%}
              {% assign gclink_handle = gclink.title | handle %}
              {%- assign link_titles = link_titles | append: gclink.title | append: '|' -%}
              {%- assign link_urls = link_urls | append: gclink.url | append: '|' -%}
              {%- assign link_levels = link_levels | append: level | append: '|'  -%}
              {%- assign link_parents = link_parents | append: clink_handle | append: '|'  -%}
              {%- assign link_handles = link_handles | append: gclink_handle | append: '|' -%}

              {%- if linklist_titles contains gclink_handle -%}
                  {% comment %} ************GET ME THE GGC {% endcomment %}
              {%- endif -%}
              {%- if forloop.last == true -%}
                {%- assign level = level | minus: 1 -%}
              {%- endif -%}
            {%- endfor -%}
                {%comment%} END GRANDCHILDREN  {%endcomment%}

      {%- endif -%}
      {%- if forloop.last == true -%}
        {%- assign level = level | minus: 1 -%}
      {%- endif -%}
    {%- endfor -%}
              {%comment%} END CHILDREN  {%endcomment%}
  {%- endif -%}
{%- endfor -%}

{%- comment -%} CONVERT TO ARRAYS {%- endcomment -%}
{%- assign str_size = link_levels | size | minus: 1 -%}
{%- assign llevels = link_levels | slice: 0, str_size | split: '|' -%}

{%- assign str_size = link_titles | size | minus: 1 -%}
{%- assign ltitles = link_titles | slice: 0, str_size | split: '|' -%}

{%- assign str_size = link_handles | size | minus: 1 -%}
{%- assign lhandles = link_handles | slice: 0, str_size | split: '|' -%}

{%- assign str_size = link_parents | size | minus: 1 -%}
{%- assign lparents = link_parents | slice: 0, str_size | split: '|' -%}

{%- assign str_size = link_urls | size | minus: 1 -%}
{%- assign lurls = link_urls | slice: 0, str_size | split: '|' -%}

{%- comment -%} GET THE DEEPEST LEVEL WE FOUND {%- endcomment -%}
{%- assign depth = '3' -%}
{%- assign bc3_parent_list_handle = '' %}

{%- comment -%} LEVEL 3: THREE DOWN FROM THE TOP {%- endcomment -%}
{%- comment -%} Do we have a link to this product or its collection on the deepest level? {%- endcomment -%}
{%- for url in lurls -%}
  {%- if object_urls contains url and llevels[forloop.index0] == depth -%}
    {%- unless url == product.url or url == collection.url -%}
      {%- capture bc3 -%}{{ ltitles[forloop.index0] | link_to: url, ltitles[forloop.index0] }}{%- endcapture -%}
    {%- endunless -%}
    {%- assign bc3_parent_list_handle = lparents[forloop.index0] -%}
    {%- assign bc3_list_handle = lhandles[forloop.index0] -%}
    {% break %}
  {%- endif -%}
{%- endfor -%}

{%- comment -%} LEVEL 2: TWO DOWN FROM THE TOP
  Next level. If we found something, we can save a lot of processing. Otherwise, 
  we need to scan everything on this level 
{%- endcomment -%}
{%- assign depth = '2' -%}
{%- assign bc2_parent_list_handle = '' %}

{%- comment -%} IF we didn't find anything on the deepest level, we start from scratch one level up {%- endcomment -%}
{%- if bc3_parent_list_handle == '' -%} 
  {%- for url in lurls -%}
    {%- if llevels[forloop.index0] == depth -%}
      {%- if object_urls contains url -%} 
        {%- unless url == product.url or url == collection.url -%}
          {%- capture bc2 -%}{{ ltitles[forloop.index0] | link_to: url, ltitles[forloop.index0] }}{%- endcapture -%}
        {% endunless %}
        {%- assign bc2_parent_list_handle = lparents[forloop.index0] -%}
        {%- break -%}
      {%- endif -%}
    {%- endif -%}
  {%- endfor -%}
  
{%- comment -%} ELSE we have a parent handle. We need its array index to find its other data {%- endcomment -%}  
{%- else -%}
  {%- for list_handle in lhandles -%}
    {%- if list_handle == bc3_parent_list_handle -%}
      {% assign bc2_list_handle = list_handle %}
      {%- assign bc2_parent_list_handle = lparents[forloop.index0] -%}
      {%- assign bc2_list_title = ltitles[forloop.index0] -%}
      {%- comment -%} we have the handle of bc2's parent, so now we get the parent links, find where title matches parent title, then we have the collection URL {%- endcomment -%}
      {%- for bc2_sibling_link in linklists[bc2_parent_list_handle].links -%}
        {%- assign bc2_sibling_title_handleized = bc2_sibling_link.title | handle -%}
        {% if bc2_sibling_title_handleized == bc2_list_handle %}
          {%- capture bc2 -%}{{ bc2_sibling_link.title | link_to: bc2_sibling_link.url, bc2_sibling_link.title }}{%- endcapture -%}
          {% break %}
        {%- endif -%}
      {%- endfor -%}
      {% break %}
    {%- endif -%}
  {%- endfor -%}
{%- endif -%}

{%- comment -%} LEVEL 1: MAIN ITEMS BELOW MAIN MENU (I.E. TOP NAV BAR) {%- endcomment -%}

{%- assign depth = depth | minus: 1 | append: '' -%}
{%- assign bc1_parent_list_handle = '' %}

{%- if bc2_parent_list_handle == '' -%} 
    {%- comment -%} IF found nothing on deepest level or middle level, we start from scratch on top level {%- endcomment -%}
  {% for url in lurls %}
    {%- if object_urls contains url and llevels[forloop.index0] == depth -%}
      {%- unless url == product.url or url == collection.url -%}
        {%- capture bc1 -%}{{ ltitles[forloop.index0] | link_to: url, ltitles[forloop.index0] }}{%- endcapture -%}
      {% endunless %}
      {%- assign bc1_parent_list_handle = lparents[forloop.index0] -%}
      {%- break -%}
    {%- endif -%}
  {%- endfor -%}

    {%- comment -%}
        ELSE we have a parent handle. We need its array index to find its other data 
    {%- endcomment -%}
{%- else -%}
  {%- for list_handle in lhandles -%}
    {%- if bc2_parent_list_handle == list_handle -%}
      {% assign bc1_list_handle = list_handle %}
      {%- assign bc1_parent_list_handle = lparents[forloop.index0] -%}
      {%- assign bc1_title = ltitles[forloop.index0] -%}
            {%- comment -%} 
              We HAVE PARENT, so now we GET THE SIBLING links, find where title 
              matches parent title, then we have the collection URL 
            {%- endcomment -%}
      {%- for bc1_sibling_link in linklists[bc1_parent_list_handle].links -%}
        {%- assign bc1_sibling_title_handleized = bc1_sibling_link.title | handle -%}
        {% if bc1_sibling_title_handleized == bc1_list_handle %}
          {%- capture bc1 -%}{{ bc1_sibling_link.title | link_to: bc1_sibling_link.url, bc1_sibling_link.title }}{%- endcapture -%} 
          {% break %}
        {%- endif -%}
      {%- endfor -%}
    {%- endif -%}
  {%- endfor -%}
{%- endif -%}

  {%- if bc1 -%}
    <span aria-hidden="true">›</span>
    {{ bc1 }}
  {%- endif -%}
  {%- if bc2 -%}
    <span aria-hidden="true">›</span>
    {{ bc2 }}
  {%- endif -%}
  {%- if bc3 -%}
    <span aria-hidden="true">›</span>
    {{ bc3 }}
  {%- endif -%}
{%- if template contains 'product' -%}
  <span aria-hidden="true">›</span>
  <span>{{ product.title }}</span>

{%- elsif template contains 'collection' and collection.handle -%}
    <span aria-hidden="true">›</span>
    <span>{{ collection.title }}</span>

{%- elsif template == 'blog' -%}

  <span aria-hidden="true">›</span>
  {%- if current_tags -%}
    {{ blog.title | link_to: blog.url }}
    <span aria-hidden="true">›</span>
    <span>{{ current_tags | join: " + " }}</span>
  {%- else -%}
  <span>{{ blog.title }}</span>
  {%- endif -%}

{%- elsif template == 'article' -%}

  <span aria-hidden="true">›</span>
  {{ blog.title | link_to: blog.url }}
  <span aria-hidden="true">›</span>
  <span>{{ article.title }}</span>

{%- elsif template contains 'page' -%}

 <span aria-hidden="true">›</span>
 <span>{{ page.title }}</span>

{%- else -%}

 <span aria-hidden="true">›</span>
 <span>{{ page_title }}</span>

{% endif %}
</nav>
{%- endunless -%}

13 Responses to “Shopify Breadcrumbs Based on Menu Structure (Improved Version)”

  1. Brie Moriarty

    Thanks for the code! Only issue is I’m seeing “translation missing: en.general.breadcrumbs.home › Shop by Category › Kitchen + Dining › Kitchen + Dining”. Any idea how to fix it? Also, can you share how to indent the breadcrumbs in from the left edge of the page as well as how to make the font smaller?

  2. The translation missing error is because you are missing some translation strings are missing from your theme. So if you did not have a translation for a “Home” breadcrumb in your theme to start with, this code will not add one. You’ll have to edit the theme language files.

    Have a look at the Shopify documentation on translation
    https://help.shopify.com/en/manual/using-themes/translate-theme#search-a-translation-for-missing-or-completed-fields

    As for the styling (fonts, margins), that’s just basic CSS. Honestly, if you don’t know how to do that, you probably shouldn’t be playing with code like this. This tutorial is really meant for developers.

  3. My dude, you just saved me many hours and for that I award you many, many thanks.

  4. caitlin

    Thank you for your generosity Tom! I was truly looking for a better breadcrumb solution.
    Basic n00b question (WordPress person dipping her toes in Shopify): My theme already has a “breadcrumb-nav.liquid” snippet. Can I throw your code in there? Or overwrite the current code with yours?

  5. Hi Caitlin. That’s the general idea. Everything you need in the navigation should be there already, so my code should go where you want the breadcrumb to appear. You might need to tweak this and that. I’ve only used it on one site – https://www.traildesigns.com – so I don’t know how robust it is, but in theory it should just work…. that’s the theory anyway.

    Let me know!

  6. Hey Tom! I came across your snippet code and it works well on my site. Thank you for sharing this!

    Although I was wondering if there is a way I can present all links along the path on the breadcrumbs. Right now it’s only showing the first level and the current page. I was hoping it would show Parent > Child > Grandchild> Collection > Product. I may have missed a step somewhere. I’d very much appreciate any help on this. Thank you!

  7. Hi Max,

    See the section above called “Limited to Three Levels.” This is a built-in limitation to Shopify. Your menu structure can only go three levels deep using the built-in Shopify menu nesting.

    You can, of course, build a menu deeper than that using straight HTML, but the Shopify menu system does not allow it and my script works only with the native Shopify menu system.

    Also, to be quite honest, if your structure goes five levels deep, you might take a step back and look again the information architecture of your site. If you have a really extensive product catalog, it might make sense, but in such cases, I’ve used things like filters and faceted search to help visitors narrow down the choices.

  8. Hello Tom. Thank you very much for this code. It is working great for my collections and products although it does not work for a menu of just pages. Any clues as to why?

  9. Hi Ryan. I went to the domain in your email address and it seems to be working for the pages in the Resources section. Do you have a different implementation where the menu is just pages? What’s the URL?

  10. Hi Tom

    This is brilliant thanks! I have a question though; I use a three level nav menu, the top level is based on Collections and the sub-levels are using the same top level Collections filtered with tags (I only have Collections for the top level). I’ve also changed my product-grid-item.liquid to always show the canonical URL. I notice that only the top level Collection is shown in my breadcrumb, and never includes the sub-levels. The canonical URL change I made doesn’t influence this at all.

    Any ideas on how my 2nd and 3rd levels can be shown in the breadcrumb?

  11. Hmmm… that’s an interesting question. I did not build it to use tags, but you do have access to them. There is a product.tags object just as there is a product.collection category. So basically, everywhere that I have code that iterates through a product.collections object, you would need to create similar code that iterates through the product.tags object and handle that accordingly.

    As it is now, my code only looks for collections and products, so when you have a top-level nav item that is a collection, and a second-level item that is a filter by tag on the same collection, it’s just going to see that as the collection, not the tag. So if you look in my code for the piece with the comment that says

    {%comment%} DETERMINE OBJECT TYPE - product, collection or page {%endcomment%}

    You need to also look for an object that is a collection filtered by a tag, figure out which tag is active, and then fold that into your breadcrumb. Once you do that and set the object_urls variable, the rest should work right up until the end of the code where it does the formatting. Then, once again, you’ll need to add some code to handle the “filter by tag” case.

    Where it’s going to be tough is at the second level. With Liquid, you do not have access to the URL in the address bar. In this case, the collection.tags object gives you all the tags on all the products in the current view. So if you have 20 products and some are tagged with blue and some are tagged with small and you are show products that are small and blue, the collections.tags object will hold the tags “small” and “blue” and it will be up to you to figure out what to do with that and how to decide. If your products only have one tag that appears in the navigation, you’re all set, but if you have a navigation link for small items, one for blue items and one for small blue items, I don’t know how you’ll figure that out with the information available.

    In that case, I think you would perhaps need to build your breadcrumbs after the page loads, getting the URL using Javascript and then either using more Javascript to parse the navigation or AJAX calls to get the navigation arrays.

  12. Thanks for the detailed response Tom! My dev skills only go so far and the only reason I stuck with a top level collection structure and using tag filtering at lower levels was to ensure the URL looks correct with sublevels showing as subsites. I managed to dynamically set my Title and Description metadata for the lower levels for SEO purposes but it’s getting increasingly difficult to manage as my product range and categories grow.

    Think I need to move to Collections for all sublevels so your breadcrumb script works properly, however then the URL structure is flat with no collection nesting. I could maybe simulate a URL structure with URL redirects and manually setting the Canonical URL for each collection – some stuff to play with this weekend I guess.

    Thanks
    Mark

Leave a Reply

XHTML: You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>