DEV Community

Daniel Pertu
Daniel Pertu

Posted on

One optional field decides whether a breadcrumb is a link, in HTML and in JSON-LD

Notifio's site has three deep page families: one page per rental site under /alerts/<slug>, one per competitor under /compare/<slug>, and the long-form guides under /guides/<slug> and /for/<slug>. Thirty-eight URLs total, most of them two levels down, all of them needing a trail above the H1.

The whole component is 54 lines, and the interesting part is a question mark.

The type

export type Crumb = {
  label: string;
  /** Omitted on the final crumb, which is the current page. */
  href?: string;
};
Enter fullscreen mode Exit fullscreen mode

That is the entire data model. A page builds the array literally:

const crumbs: Crumb[] = [
  { label: "Home", href: "/" },
  { label: "Supported sites", href: "/alerts" },
  { label: site.name },
];
Enter fullscreen mode Exit fullscreen mode

Three entries, and the last one has no href because you are standing on it.

Rendered once as HTML

<nav aria-label="Breadcrumb" className="mb-6">
  <ol className="flex flex-wrap items-center gap-x-2 gap-y-1 ...">
    {crumbs.map((crumb, i) => (
      <li key={crumb.label} className="flex items-center gap-2">
        {crumb.href ? (
          <Link href={crumb.href}>{crumb.label}</Link>
        ) : (
          <span aria-current="page">{crumb.label}</span>
        )}
        {i < crumbs.length - 1 && (
          <span aria-hidden="true">/</span>
        )}
      </li>
    ))}
  </ol>
</nav>
Enter fullscreen mode Exit fullscreen mode

Three details in there that are not decoration:

aria-current="page" goes on the crumb without an href. That is what tells a screen reader the difference between "a link you have not followed" and "where you are". It falls out of the same crumb.href check that decides whether to render a Link, so the two can never disagree: there is no way to produce a linked crumb that also claims to be the current page.

aria-hidden="true" on the separator. The slash is a visual convention. Announced, it is the word "slash" between every item in a list that is already a list, which is noise. The <ol> carries the ordering; the / only has to look right.

key={crumb.label} rather than the index. Labels inside one trail are unique by construction (a page is not nested under itself), and keying by label means a navigation from /alerts/kamernet to /alerts/pararius reuses the two shared crumbs and swaps only the third.

Rendered again as structured data

The same array goes through a second function in the same file:

export function breadcrumbListLd(crumbs: Crumb[], origin: string) {
  return {
    "@type": "BreadcrumbList",
    itemListElement: crumbs.map((crumb, i) => ({
      "@type": "ListItem",
      position: i + 1,
      name: crumb.label,
      ...(crumb.href ? { item: `${origin}${crumb.href}` } : {}),
    })),
  };
}
Enter fullscreen mode Exit fullscreen mode

position: i + 1, because BreadcrumbList positions are one-based and array indices are not. And the spread, which is the same optional field doing the same job on the other side of the fence: a crumb with an href gets an absolute item URL, a crumb without one gets a ListItem that has a name and position and nothing to point at.

That last part is deliberate, because the obvious alternative is to give every entry an item including the current page. Google's breadcrumb documentation is explicit that you do not have to:

If the breadcrumb is the last item in the breadcrumb trail, item is not required. If item isn't included for the last item, Google uses the URL of the containing page.

So the omission costs nothing and buys consistency: the visible trail has nothing to click on the last crumb, so the machine-readable trail has nothing to follow either. The same page says position 1 signifies the beginning of the path, which is where the i + 1 comes from.

You can check the output on a live page. From notifio.app/alerts/kamernet:

{
  "@type": "BreadcrumbList",
  "itemListElement": [
    { "@type": "ListItem", "position": 1, "name": "Home",
      "item": "https://notifio.app/" },
    { "@type": "ListItem", "position": 2, "name": "Supported sites",
      "item": "https://notifio.app/alerts" },
    { "@type": "ListItem", "position": 3, "name": "Kamernet" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Two items, three list elements. The visible trail on that page reads Home / Supported sites / Kamernet with the first two as anchors, which is the same statement in the other language.

Why origin is a parameter

breadcrumbListLd does not know the site's URL. It takes it:

breadcrumbListLd(crumbs, APP_URL)
Enter fullscreen mode Exit fullscreen mode

where each page has

const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "https://notifio.app";
Enter fullscreen mode Exit fullscreen mode

The reason is that item has to be absolute, while href on a <Link> has to be relative. Those are not the same string and there is no safe way to convert one into the other inside a presentational component, because the answer depends on the deployment. Passing the origin in means the preview deployments emit their own origin and production emits production's, and the component stays a pure function of its arguments.

Where it gets used

Five page files plus the shared article layout:

src/app/alerts/page.tsx
src/app/alerts/[site]/page.tsx
src/app/compare/page.tsx
src/app/compare/[target]/page.tsx
src/app/guides/page.tsx
src/components/ArticleLayout.tsx
Enter fullscreen mode Exit fullscreen mode

On the deep pages the LD does not go out on its own. It is one node in a single @graph, next to the FAQ and the application description:

const ld = {
  "@context": "https://schema.org",
  "@graph": [
    breadcrumbListLd(crumbs, APP_URL),
    softwareApplicationLd(),
    { "@type": "FAQPage", ... },
  ],
};
Enter fullscreen mode Exit fullscreen mode

with a comment that explains the choice: one graph rather than three script tags, so the FAQ, the trail and the application resolve as one connected description of the page instead of three strangers who happen to share a URL.

Compare the trails on notifio.app/compare/rentbird and notifio.app/guides if you want to see the same 54 lines produce three different depths. The guides index is the shallow case, two crumbs, and the last one still has no href.

Top comments (0)