← Insights

Shopify breadcrumb schema: why the theme has none, and one snippet for every template

Neither Dawn nor Horizon emits a BreadcrumbList, and flat Shopify URLs give Google no hierarchy. What the markup needs, which collection to name, and a snippet.

11 min read

Contents — 7 sections

The trail under a result is the one place a flat URL can say where a page sits

Google's breadcrumb documentation describes the feature in one sentence: a breadcrumb trail indicates the page's position in the site hierarchy, and a searcher can walk up that hierarchy one level at a time from the last crumb. The visual elements gallery says where it appears. The visible URL under a result's title has two parts, the domain and the breadcrumb, and the breadcrumb is the part you control with BreadcrumbList markup.

Two facts about the feature decide how much of an afternoon it deserves. The first is where it shows. Since 23 January 2025, Google no longer shows breadcrumbs on mobile results in any language or region, because the trail was being cut off on small screens; the documentation's feature availability section now says desktop only. Check your own analytics for the desktop share of sessions before you decide what this is worth, and read the rest of this page knowing that the trail is a desktop element. The second is what Google does with it beyond display. The ecommerce site structure guide says Google generally does not look at the structure of URLs to work out the structure of a site; it analyses the links between pages, and it recommends structured data to help it understand the purpose of the different pages and reinforce that structure. That is the reason a Shopify store needs the markup, and it holds on every device.

Shopify's URLs are flat, and neither the platform nor the theme fills the gap

A Shopify product lives at /products/handle, whichever collection it belongs to. A collection lives at /collections/handle, whichever menu it hangs under. There is a collection-scoped product URL, /collections/bags/products/tote, produced by the within filter, and the filter's own reference carries a caution: a standard product page and a product page in the context of a collection have the same content on separate URLs, so consider the SEO implications. Both Dawn and Horizon consider them by never using the filter, and both write <link rel="canonical" href="{{ canonical_url }}"> from the canonical_url object, which for a product is always the flat URL.

The platform adds nothing on top. The structured_data filter works on product and article objects and nothing else; it emits a Product or ProductGroup and an Article, and that is the full list. Nor does the theme. We pulled both reference themes from GitHub on 17 September 2026 — Dawn, 88 Liquid files, and Horizon, 283 — and searched them for the word breadcrumb. Neither contains it, in any file. What they do emit is an Organization node from the header section, a WebSite node on the home page in Dawn, the product node from the filter, and an Article on blog posts. No template in either theme has a visible trail, and no template has the list.

Google's recommendation is a trail that represents a typical user path to the page instead of mirroring the URL structure — the URL structure being what it has to fall back on. On a Shopify store that fallback is yourstore.com › products › tote under a product and yourstore.com › collections › bags under a collection: a directory name, not a category. The collection a product belongs to, which is the one thing a category shopper wants to know about a product result, appears nowhere.

Google asks for little, and two of its rules decide the design

The type definition is short. A BreadcrumbList with at least two ListItems. Each item carries a position, an integer starting at 1 for the top of the trail; a name, the text a searcher sees; and an item, the URL of the page that crumb represents. The item is not required on the last crumb, and if you leave it off Google uses the URL of the containing page. The schema.org definition adds the convention: ascending positions, the first item nearest the top of the site, typically ending with the current page.

Two things in the guidelines matter more than the properties.

The first is what the trail should be. Google says explicitly that neither the top level — your domain — nor the page itself is required, and it recommends the path a person would typically take. On Shopify the typical path is the menu: a shopper reaches the tote through Bags, because Bags is what the header says, and that is the trail to write — not products, and not whichever of six collections the product happens to be listed in first. Google also permits more than one trail on a page, for a page reachable by different routes; a product in two menu categories can carry two lists. Start with one.

The second is the visibility rule. Google's general structured data guidelines say not to mark up content that is not visible to readers of the page. A BreadcrumbList in a script tag with no breadcrumb anywhere on the page is exactly that. The snippet below renders the visible trail and the JSON-LD from the same array, so they cannot disagree, and neither exists without the other.

One thing from history, because it still turns up in the source of older stores. Google's documentation updates record that data-vocabulary.org markup stopped being eligible for rich results on 29 January 2021. Themes and apps built before then sometimes still emit a data-vocabulary breadcrumb, and it is now decoration.

Which collection a product belongs to is a decision Liquid will not make for you

On a collection page the trail is obvious. On a product page it is not, because Shopify has no notion of a product's primary collection. The product object exposes product.collections, the collections the product belongs to — restricted to those available on the Online Store channel — and says nothing about which one comes first or why. A product in Bags, Totes, Sale, New In and the home page collection has five parents, and product.collections.first picks one of them for a reason you did not choose.

Three ways to choose, in the order we prefer them.

  • The menu. The linklists object exposes every menu by handle, and each link carries its url, title and child links. Walk the main menu and take the first of the product's collections that appears in it, at either level. This is the typical user path Google asks for, it is deterministic, and it changes when the merchant changes the menu — which is where they would expect to change it.
  • The collection in the URL. When a product is reached through a within URL, the collection object is set on the product template. Use it when it is present, because it is the exact path the shopper took. Do not rely on it, because Dawn and Horizon never produce that URL, and the canonical strips it anyway.
  • The first collection that is not automatic. The fallback for a product that is in no menu collection: skip the home page collection, whose handle is frontpage on most stores, and the catalogue collection at /collections/all, and take the first that remains. Arbitrary, but stable, and better than products.

Whichever wins, the crumb's item is collection.url, the flat one, and the product crumb carries no item at all so that Google takes the canonical. A trail whose links point at /collections/bags/products/tote reintroduces the duplicate URL the theme was avoiding.

Check it in five minutes, on one collection and one product

Open a collection page on the live store and view the source. Search for BreadcrumbList. Do the same on a product page. If neither returns anything, you are where both reference themes leave you, and the rest of this page is the fix. If one does, it came from an app or a previous developer, and you need to know what it says.

On the live page, open DevTools and in the Console run:

[...document.querySelectorAll('script[type="application/ld+json"]')]
  .map(s => JSON.parse(s.textContent))
  .flatMap(d => Array.isArray(d) ? d : (d['@graph'] ?? [d]))
  .filter(n => n['@type'] === 'BreadcrumbList')
  .map(n => n.itemListElement.map(i => i.position + ' ' + i.name + ' ' + (i.item ?? '')))

It returns one array per list on the page, with a row per crumb: position, name, URL. It is the same line we use to find duplicate Product nodes, filtered to the other type. Read it for four things.

  • More than one list. One from an app and one from the theme, each a different route to the page. Google permits multiple trails, but only when both are true; two apps guessing is not that.
  • A products crumb. An app that mirrors the URL path emits Home › Products › Tote. That is the fallback trail written out longhand, and it says nothing the URL did not.
  • A relative or collection-scoped URL. item must be a full URL, and it should be the canonical: the store's origin followed by /collections/bags, not the bare path and not a within URL.
  • Rows with no visible trail. If the console prints a list and there is no breadcrumb on the page, the visibility guideline is being broken on your behalf.

Then paste the same two URLs into the Rich Results Test. It reports the detected item types; you want Breadcrumbs listed with an item count and no errors. Warnings are non-critical. The Schema Markup Validator checks the same JSON against the vocabulary without Google's eligibility rules on top, and it is the one to use for a trail that is valid but that Google would not display. In Search Console, the Breadcrumbs report under Enhancements is the ongoing version; the January 2025 blog post confirms the report continues even though mobile display stopped.

Last, search Google on a desktop for a product you sell, by name, and look at the line under the title. That is the trail as it stands, and it is the before picture.

One snippet renders the trail and the list from the same array

Create snippets/breadcrumbs.liquid:

{%- liquid
  assign names = ''
  assign urls = ''

  case request.page_type
    when 'collection'
      assign names = collection.title
      assign urls = collection.url

    when 'product'
      assign parent = false
      if collection
        assign parent = collection
      endif
      unless parent
        for c in product.collections
          for link in linklists.main-menu.links
            if link.url == c.url
              assign parent = c
              break
            endif
            for child in link.links
              if child.url == c.url
                assign parent = c
                break
              endif
            endfor
            if parent
              break
            endif
          endfor
          if parent
            break
          endif
        endfor
      endunless
      unless parent
        for c in product.collections
          unless c.handle == 'frontpage' or c.handle == 'all'
            assign parent = c
            break
          endunless
        endfor
      endunless
      if parent
        assign names = parent.title | append: '|' | append: product.title
        assign urls = parent.url | append: '|' | append: product.url
      else
        assign names = product.title
        assign urls = product.url
      endif

    when 'article'
      assign names = blog.title | append: '|' | append: article.title
      assign urls = blog.url | append: '|' | append: article.url

    when 'page'
      assign names = page.title
      assign urls = page.url
  endcase
-%}

{%- if names != blank -%}
  {%- liquid
    assign names = 'Home|' | append: names | split: '|'
    assign urls = routes.root_url | append: '|' | append: urls | split: '|'
  -%}
  <nav class="breadcrumbs" aria-label="Breadcrumb">
    <ol class="breadcrumbs__list">
      {%- for name in names -%}
        <li class="breadcrumbs__item">
          {%- if forloop.last -%}
            <span aria-current="page">{{ name | escape }}</span>
          {%- else -%}
            <a href="{{ urls[forloop.index0] }}">{{ name | escape }}</a>
          {%- endif -%}
        </li>
      {%- endfor -%}
    </ol>
  </nav>
  <script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    "itemListElement": [
      {%- for name in names -%}
        {
          "@type": "ListItem",
          "position": {{ forloop.index }},
          "name": {{ name | json }}
          {%- unless forloop.last -%}
            , "item": {{ urls[forloop.index0] | prepend: request.origin | json }}
          {%- endunless %}
        }{%- unless forloop.last -%},{%- endunless -%}
      {%- endfor -%}
    ]
  }
  </script>
{%- endif -%}

Then open layout/theme.liquid and, inside the <main> element, put {% render 'breadcrumbs' %} on the line before {{ content_for_layout }}. In Dawn that is the block with id="MainContent"; Horizon's is the same element. The trail is in the initial HTML, above the first section, so it moves nothing once painted — the opposite of the app-injected bars that show up in a layout shift audit. Give it a few lines in the theme's stylesheet, using whatever page-width class the theme already has:

.breadcrumbs__list { display: flex; flex-wrap: wrap; gap: 0.5rem; list-style: none; margin: 0; padding: 1rem 0; }
.breadcrumbs__item + .breadcrumbs__item::before { content: "›"; margin-right: 0.5rem; }

The choices, because each one is a bug we have seen live:

  • request.page_type rather than template.name. The request object reports the type of page being served, and it is the reason the snippet renders nothing on the home page, the cart, search and the customer pages: names stays blank, and the if around the output does the rest. The list needs at least two items, and those pages do not have them.
  • escape on the visible names and json on the list names. A product called 12" Vinyl Tote produces a broken JSON block otherwise, and a broken block is no markup at all.
  • request.origin on item. Google wants full URLs. collection.url is relative, and the request object's origin is the documented way to make it absolute without hard-coding the domain.
  • No item on the last crumb. Google uses the containing page's URL, which is the canonical. Writing product.url there would be harmless on the product page and wrong in any context where it carries a variant parameter, so it is left off.
  • Home, included. Google says the top level is not required. A collection page has one ancestor, the list needs two items, and one rule for every template is easier to check than two. The routes object's root_url keeps it correct on a store with more than one language; the word Home does not, so on such a store add a key to locales/en.default.json and use the t filter in its place.
  • The | delimiter. Liquid has no array literal, so the two lists travel as strings and are split. A title containing a pipe breaks that; if you have one, change the delimiter to something you do not.

The version that takes longer is nesting. If the main menu has Bags above Totes, the collection page for Totes should read Home › Bags › Totes, and the product under it should read four deep. That is the same walk through linklists.main-menu with one more level, inserting the parent link's title and url ahead of the collection when the match was found among its children. It is an hour, and the snippet above is valid without it.

Add the snippet first; the whole job is under two hours

In this order, because each step is what makes the next one measurable:

  1. The snippet and the render line. Thirty minutes including the stylesheet. Nothing can be tested until a list exists.
  2. The collection rule. Thirty minutes, most of it reading the main menu and deciding whether it is the hierarchy you want published under your results. If the menu is Shop, About and Contact with every collection under Shop, fix the menu — the trail is only ever as good as the navigation it mirrors.
  3. Duplicates. Ten minutes. Run the console line, switch off any breadcrumb output in an app's settings or its app embed, and delete any data-vocabulary block you find.
  4. Two URLs through the test, then wait. Request indexing in Search Console and watch the Breadcrumbs report. Days, and a single reading is a data point rather than a trend.

It belongs in the AI visibility section of a store audit, next to the product node, rather than in a project of its own. If you would rather have it found, ranked against everything else the store is leaving on the table and written up, it is section five of the Store Teardown — €750, five days, and the document is yours whatever you decide afterwards.

Own your growth.Start with the teardown.

Start with a Teardown. Read it, then decide whether you want us to fix what it finds.