Shopify Breadcrumbs: Collection Trails, Products, Schema
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.nameandroutes.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 &, 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:
- Add a primary collection. A
custom.primary_collectionproduct 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. - 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. - 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
- Add
parent_category(and optionallybreadcrumb_label) and fill the top few levels of collections. - One snippet resolves the chain; the trail and the JSON-LD both read it.
- Decide the product page: primary collection, or flatten.
- Move the product H1 out of the nav and repair the CSS.
- 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.