Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

512 of our 516 pages carry a breadcrumb trail, and not one of them names the page you are on

Munchable's website is 516 URLs, and 512 of them ship a breadcrumb. Some of that breadcrumb is drawn on the page for a reader, and some of it is in a <script type="application/ld+json"> block for a crawler. I had always

Munchable's website is 516 URLs, and 512 of them ship a breadcrumb. Some of that breadcrumb is drawn on the page for a reader, and some of it is in a <script type="application/ld+json"> block for a crawler. I had always assumed those two were the same list rendered twice.

They are not. They overlap in exactly one item.

What a reader sees, and what a crawler reads

Here is the whole visible trail on munchable.app/is-onion-low-fodmap, copied out of the live HTML:

<nav class="crumb-nav" aria-label="Breadcrumb">
  <a href="/answers">Ingredient answers</a>
  <span aria-hidden="true">/</span>
  <span>Onion</span>
</nav>

And here is the BreadcrumbList on the same page, decoded:

1  Home               https://munchable.app/
2  Ingredient answers https://munchable.app/answers

Two items each. One item in common. The visible trail starts at the parent and ends at the page; the structured trail starts at the site root and stops one short of the page. The reader is told where they can go back to. The crawler is told what this page hangs off. Neither description is wrong, and no line of code anywhere asserts that they should agree.

The shape repeats across the whole site:

Pages Visible trail Structured trail
461 ingredient answers Ingredient answers / Onion Home, Ingredient answers
38 recipes Recipes / recipe title Home, Recipes
8 condition guides Conditions / condition Home, Conditions
1 reference page Coeliac disease / Hidden gluten Home, Conditions
4 index pages none Home
4 legal and landing pages none none

Two details fall out of that table before anything else.

The four index pages (/answers, /conditions, /recipes, /about) emit a BreadcrumbList of one item, naming the homepage. A trail of length one, on a page that is not the page the trail names, with nothing visible on screen to match it. It is not malformed, it is just not saying anything.

And "Home" appears in the markup of 512 pages while appearing in the visible trail of none of them. The way home is the logo in the header, which every page has, so a reader is not stranded. But the trail a crawler reconstructs has a step in it that no reader is ever offered.

The page where the two actually disagree

The newest page on the site is a reference list, hidden gluten ingredients. Its visible trail says its parent is the coeliac disease guide, which is true: that guide is where the page is linked from, and it is the page a reader came through.

Its structured trail says its parent is /conditions, the index.

Both statements are defensible. They are also different statements, and the reason they differ is not a decision anybody made. It is that the page has two blocks of code, about fifty lines apart, and I wrote the nearer one while thinking about a reader and the further one while copying the pattern off a sibling page:

breadcrumbLd([
  { name: 'Home', path: '/' },
  { name: 'Conditions', path: '/conditions' },
]),
<nav className="crumb-nav" aria-label="Breadcrumb">
  <Link href="/conditions/coeliac-disease">Coeliac disease</Link>
  <span aria-hidden="true">/</span>
  <span>Hidden gluten</span>
</nav>

Eight pages call breadcrumbLd. Four page templates render crumb-nav. The two sets are not the same set, nobody passes the trail from one to the other, and the helper itself is nine lines that take whatever array it is handed:

export function breadcrumbLd(items: ReadonlyArray<{ name: string; path: string }>) {
  return {
    '@type': 'BreadcrumbList',
    itemListElement: items.map((item, i) => ({
      '@type': 'ListItem',
      position: i + 1,
      name: item.name,
      item: absoluteUrl(item.path),
    })),
  };
}

A helper with no opinion is a helper that cannot stop you. This is the second time structured data on this site has drifted for exactly that reason: the last one was the publisher node that 47 pages described twice, because a page template and the root layout both volunteered it.

Is any of this a bug?

Honestly, mostly no, and that is the interesting part.

Google's own breadcrumb examples end at the page itself, and its documentation is happy with a trail that stops short of the current page, so a two item list ending at the parent is valid markup that will render a perfectly good breadcrumb in a search result. The one item lists on the index pages are valid too. Nothing here is going to cost impressions.

The thing that is a defect is smaller and has nothing to do with search. The last item in every visible trail is a bare <span>, and there are zero aria-current attributes on any of the 516 pages. A screen reader user lands in a navigation landmark called "Breadcrumb", hears a link and then a word, and nothing in the markup says which of those two is the page they are on. The separator is correctly aria-hidden, which means somebody (me) was thinking about assistive technology in that exact element and still left the "you are here" part implicit.

So the fix I want is not "make the JSON match the HTML". It is to stop having two trails. One function per page type takes the trail once, as data, and returns both the nodes for the graph and the elements for the nav, with the last item marked aria-current="page" and left out of the structured list on purpose rather than by accident. The trail becomes a property of the page, which is what it always was.

Check it yourself

View source on any of these and search for BreadcrumbList, then compare it with the trail above the heading:

If you have a site with both kinds of breadcrumb, the five minute version of this audit is to fetch your sitemap, pull the BreadcrumbList out of each page, and diff its last item against your visible trail's last item. Mine disagreed on every single page, and I would never have noticed from reading either one on its own.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.