DEV Community

Daniel Pertu
Daniel Pertu

Posted on

The breadcrumb you click and the BreadcrumbList a crawler parses are the same array, and our homepage renders neither

Save this as crumbs.sh and point it at any page on the site:

curl -s "$1" | python3 -c "
import sys, json, re
html = sys.stdin.read()
for blob in re.findall(r'<script type=\"application/ld\+json\">(.*?)</script>', html, re.S):
    data = json.loads(blob)
    if data.get('@type') == 'BreadcrumbList':
        print([(i['position'], i['name']) for i in data['itemListElement']])
print('visible trail:', 'aria-label=\"Breadcrumb\"' in html)
"
Enter fullscreen mode Exit fullscreen mode

Three runs against the live site:

$ ./crumbs.sh https://pub-trivia.app/guides/pub-quiz-format
[(1, 'Home'), (2, 'Guides'), (3, 'Quiz format')]
visible trail: True

$ ./crumbs.sh https://pub-trivia.app/guides
[(1, 'Home'), (2, 'Guides')]
visible trail: True

$ ./crumbs.sh https://pub-trivia.app/
visible trail: False
Enter fullscreen mode Exit fullscreen mode

The homepage has no breadcrumb markup and no breadcrumb nav. Those two absences are the same if, which is the point of this post.

The thing that goes wrong when there are two of them

Breadcrumbs are a rendered list of links and a BreadcrumbList JSON-LD node, and the overwhelmingly common way to build them is twice. The nav gets written in the layout. The structured data gets written later, when somebody notices Search Console reporting nothing, and by then it is a separate function with its own idea of the hierarchy.

Then they drift, and the drift is invisible. Nobody view-sources their own site. A crawler sees markup claiming a trail the page does not render, which is a specific category of wrong: not missing data, but a declared hierarchy that contradicts the visible one. Ours is one component:

export function Breadcrumbs({ path }: { path: string }) {
    const trail = breadcrumbFor(path)
    if (trail.length < 2) return null

    return (
        <>
            <JsonLd data={breadcrumbLd(path)} />
            <nav aria-label="Breadcrumb" className="mb-6">
                {/* the same `trail`, as <ol><li><Link> */}
            </nav>
        </>
    )
}
Enter fullscreen mode Exit fullscreen mode

breadcrumbLd(path) calls breadcrumbFor(path) as well, and carries the identical guard:

export function breadcrumbLd(path: string) {
    const trail = breadcrumbFor(path)
    if (trail.length < 2) return null
    // ... itemListElement: trail.map(...)
}
Enter fullscreen mode Exit fullscreen mode

Two call sites, one source. The markup cannot describe a trail the reader is not looking at, because there is no second trail to describe.

Why a lone "Home" renders nothing at all

trail.length < 2 is the homepage, and the reason the homepage gets nothing is editorial rather than technical. A one item breadcrumb is a link to the page you are already on, under a chevron pointing at nothing. It is noise in the layout and it is noise in the markup, where a BreadcrumbList with a single ListItem tells a crawler precisely what it already knew from the URL.

The small mechanism that makes this cheap: the JSON-LD wrapper returns null for anything falsy.

export function JsonLd({ data }: { data: unknown }) {
    if (!data) return null
    // ... one <script type="application/ld+json">
}
Enter fullscreen mode Exit fullscreen mode

So "this page has no trail" is expressible as null all the way out to the DOM, with no page having to know whether it is the exception.

The trail comes from a registry, not from the URL

The obvious implementation is to split the pathname and title case the segments. We do not, because /quiz-questions/film-and-tv becomes "Quiz questions" and "Film and tv", and the second one is wrong in a way you cannot fix without a lookup table, at which point you have a registry with extra steps.

Every public content page has a node in one TypeScript module, and the trail is built from three facts in it:

export function breadcrumbFor(path: string): Crumb[] {
    const node = nodeFor(path)
    if (!node || node.path === '/') return []

    const trail: Crumb[] = [{ label: 'Home', path: '/' }]
    const cluster = clusterOf(path)
    if (cluster && cluster.path !== path) {
        trail.push({ label: cluster.label, path: cluster.path })
    }
    trail.push({ label: labelFor(node), path: node.path })
    return trail
}
Enter fullscreen mode Exit fullscreen mode

Three levels, never more, because the site is a hub and spoke content plan and not a file tree. Membership comes from the node's cluster field rather than from the URL, so a page could in principle sit under one prefix and belong to another cluster without the trail lying about it.

labelFor() is the other half:

export function labelFor(node: ContentNode): string {
    return node.shortLabel ?? node.title
}
Enter fullscreen mode Exit fullscreen mode

That is why the third crumb on /guides/pub-quiz-format reads "Quiz format" while the page title is "Pub Quiz Format | PubTrivia". A breadcrumb is not a title and it should not be read as one. Both the rendered crumb and the name in the JSON-LD get the short form, because they are the same string.

The trail is also the link graph

The part I did not anticipate when this was written. There is a test suite over the content graph, and one of its assertions is that no page is an orphan: every page needs an inbound internal link from somewhere, and for the six hubs the header and footer are allowed to count, which is the whole reason the hubs are in the footer.

The function that answers "what does this page link to" starts with the breadcrumb:

const links = new Set<string>()
for (const crumb of breadcrumbFor(path)) {
    if (crumb.path !== path) links.add(crumb.path)
}
Enter fullscreen mode Exit fullscreen mode

Which means no spoke page needs to remember to link up to its hub, and no code review needs to check for it, because a page cannot be rendered without rendering its own ancestors. The hub gets an inbound internal link from all of its spokes as a structural consequence of the layout, and the test counting those links is reading the same function the component renders.

Worth checking on your own site, and it takes one command: if the BreadcrumbList in your HTML does not match the chevrons in your nav, one of the two has been edited since the other.

The pages above are all live if you want to compare them: a spoke at /guides/pub-quiz-format, its hub at /guides, a two level page at /pricing, and the homepage with neither. The app itself has a free tier and needs no card, if you would rather use it than read its markup.

Top comments (0)