Nikola Arsić

Shopify Breadcrumbs: Collection Trails, Products, Schema

Nikola ArsićShopify

Shopify breadcrumbs go wrong in a predictable way. The trail you see on the page and the BreadcrumbList in the JSON-LD are written by two different snippets, nobody keeps them in sync, and on product pages the category in the middle is picked by a rule nobody chose.

This is the order I work in on Shopify stores, including the part most guides skip: deciding what a product page's breadcrumb should be in the first place.

What breadcrumbs are worth

Be realistic before you spend a sprint on this. Google treats BreadcrumbList as a way to understand and display site structure, not as a ranking lever, and since January 2025 it no longer shows breadcrumb trails in mobile snippets (desktop still does). What you get from a correct trail is cleaner internal linking up the hierarchy, a schema block that describes the page a user actually sees, and a navigation aid.

What you get from a wrong one is worse than having none: a misleading category on the page and in the schema. So the goal is correct, not elaborate.

1. Shopify has no collection hierarchy - add one

A Shopify collection has no parent. Collections are flat, which is fine for merchandising and useless for a breadcrumb that is supposed to say Shoes > Football > Cleats.

The reliable fix is two collection metafields (Settings → Custom data → Collections):

custom.parent_category   Collection reference (single value)
custom.breadcrumb_label  Single line text, optional

Leave the parent empty on a top-level collection. The label is optional and falls back to the collection title; it earns its place when the title is written for the page ("Men's Football Cleats & Boots") and the crumb should be short ("Cleats").

Create the metafield definitions in the admin by hand. The point of a definition is that it is a deliberate, typed field a merchant can see, not a free-text value somebody typed once.

2. Walk the chain in Liquid, with a guard

Liquid has no recursion, so walk upward from the current collection with a bounded loop and collect two parallel lists:

{% assign crumb_labels = '' %}
{% assign crumb_urls = '' %}
{% assign seen_handles = '|' %}
{% assign current = collection %}

{% for i in (1..10) %}
  {% if current == blank %}{% break %}{% endif %}

  {% assign needle = current.handle | prepend: '|' | append: '|' %}
  {% if seen_handles contains needle %}{% break %}{% endif %}
  {% assign seen_handles = seen_handles | append: current.handle | append: '|' %}

  {% assign label = current.metafields.custom.breadcrumb_label %}
  {% if label == blank %}{% assign label = current.title %}{% endif %}

  {% assign crumb_labels = label | append: '||' | append: crumb_labels %}
  {% assign crumb_urls = current.url | append: '||' | append: crumb_urls %}

  {% assign current = current.metafields.custom.parent_category.value %}
{% endfor %}

{% assign crumb_labels = crumb_labels | split: '||' %}
{% assign crumb_urls = crumb_urls | split: '||' %}

Two details matter. The loop is capped at ten levels, which is also your depth limit with no extra code. And the seen_handles check stops a circular reference, a collection that is its own grandparent, from hanging the render. The handles are wrapped in pipes on both sides so cleats cannot falsely match inside an already-seen long-cleats.

This guard is a safety net, not validation. Shopify will let a merchant create the bad reference in the admin, so on a store with many collections it is worth a periodic audit.

3. Make the trail and the schema come from the same data

Render the visible trail and the JSON-LD from the same two lists, so they cannot drift. Two rules for the trail:

  • The last crumb is the current page and is not a link. A page linking to itself is a known accessibility anti-pattern. Give it a different class from the linked crumbs, not the same class without an href, or theme hover rules will still make it look clickable.
  • The root crumb uses shop.name and routes.root_url, not a hardcoded "Home". If the schema says "Acme Tackle" and the page says "Home", Google reads the schema and a visitor reads the page, and they are describing different things.

And for the schema:

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    { "@type": "ListItem", "position": 1,
      "name": {{ shop.name | json }}, "item": {{ shop.url | json }} }
    {% for label in crumb_labels %}
    ,{ "@type": "ListItem", "position": {{ forloop.index | plus: 1 }},
       "name": {{ label | json }}
       {% unless forloop.last %}, "item": {{ crumb_urls[forloop.index0] | prepend: shop.url | json }}{% endunless %} }
    {% endfor %}
  ]
}
</script>

Always pass names through | json, never wrap them in quotes by hand: a collection called Men's "Pro" Cleats or anything with an ampersand will break hand-quoted JSON. collection.url is relative, so prepend shop.url for the schema item. The final item may omit item, which matches the plain-text last crumb on the page.

One store-wide check while you are here: look at Settings → Store details. If the store name was typed with a literal &amp;, the page and the schema will both render it, consistently and wrongly.

4. The product page is the hard part

On a collection page the hierarchy is the page's actual place in the site. A product is different: it belongs to many collections, and Shopify gives you no primary one.

Shopify does tell you which collection a visitor came through - the collection object exists on /collections/x/products/y. But it is empty on the product's canonical URL, which is exactly what Googlebot crawls, and empty on direct links and internal search. The usual fallback is product.collections.first, and on the stores I have worked on that list does not come back in an order that expresses what the product is. The result is a crumb that is whichever collection sorts first, which can be Accessories, or a bare /collections/all.

So on the pages where the schema matters most, the middle crumb is arbitrary. You have three honest options:

  1. Add a primary collection. A custom.primary_collection product metafield, set deliberately, then walk the chain from it exactly as above. This is the right answer when products reliably have one real home and you are willing to maintain the field.
  2. Flatten. Product breadcrumb is Shop name > Product name. No category crumb, so no wrong one. This is what I did on a store where the category crumb was frequently wrong, and I would do it again.
  3. Keep the chain and just fix the labels. If products reliably sit in one meaningful collection already, keep the hierarchy and only make the root label match the schema.

I default to flattening unless there is a primary collection to use, because a correct two-level trail beats a three-level one with a coin flip in the middle. Flattening also deletes the whole chain-walk from the product template, which is less code to keep.

If you flatten, the product trail is just:

<nav class="product-breadcrumbs">
  <a href="{{ routes.root_url }}">{{ shop.name }}</a> ›
  <span class="breadcrumb-current" aria-current="page">{{ product.title }}</span>
</nav>
<h1 class="product-title">{{ product.title }}</h1>

5. Check where the H1 lives

Some themes render the product <h1> as a child of the breadcrumb <nav>, with flex tricks to separate them visually. A breadcrumb landmark should contain the trail and nothing else, and a heading inside a navigation region muddies how crawlers and screen readers read the page.

Move the <h1> out to be a sibling of the <nav>. Then fix the CSS properly: selectors scoped to the old nesting (flex-basis: 100% on a nav descendant, or mobile order values that assumed the heading travelled with the nav) need to be rewritten for two independent flex children. Moving the markup and leaving the CSS alone is the common half-fix, and it shows up as a broken mobile layout.

Order of work

  1. Add parent_category (and optionally breadcrumb_label) and fill the top few levels of collections.
  2. One snippet resolves the chain; the trail and the JSON-LD both read it.
  3. Decide the product page: primary collection, or flatten.
  4. Move the product H1 out of the nav and repair the CSS.
  5. Validate a handful of real URLs in the Rich Results Test and compare the schema text to the visible trail character for character.

Checking where a store's breadcrumbs disagree with their schema is a standard part of a Shopify SEO review. The rest of the checklist is in the Shopify SEO guide.