DEV Community

Roger Rajaratnam
Roger Rajaratnam

Posted on • Originally published at sourcier.uk

Breadcrumb navigation with Schema.org markup

Original post: Breadcrumb navigation with Schema.org markup

Series: Part of How this blog was built — documenting every decision that shaped this site.

Breadcrumbs are one of those components that look simple but have several layers
of correctness: the visual trail, the accessible markup, and the structured data
for search engines. All three matter and only one of them is visible to users.

Breadcrumb.astro handles all three, and it does so with a fallback auto-generation
system that derives the crumb list from the URL path when no explicit list is provided.

Breadcrumb component in two contexts — inverted on the dark post hero and default on a light surface — with annotations for aria-current, BreadcrumbList schema markup, and the SERP breadcrumb trail it produces

Diagram fallback for Dev.to. View the canonical article for the original SVG: https://sourcier.uk/blog/breadcrumb-schema-astro

The component interface

interface Crumb {
  label: string;
  href?: string;
}

interface Props {
  crumbs: Crumb[];
  inverted?: boolean;
  noContainer?: boolean;
}
Enter fullscreen mode Exit fullscreen mode

crumbs is an array of label/href pairs. The final crumb in the list should have
no href — it represents the current page and shouldn't be a link. The inverted
prop flips the colour scheme for placement on dark backgrounds (like the post hero).
The noContainer prop skips the .container wrapper, used when the breadcrumb
sits inside a layout that already handles max-width constraints.

The component prepends a "Home" crumb automatically so callers don't have to include
it in every usage:

const allCrumbs = [{ label: "Home", href: "/" }, ...crumbs];
Enter fullscreen mode Exit fullscreen mode

Auto-generation from URL path

In PageHero.astro, if no explicit crumbs prop is passed, the component falls
back to deriving them from the URL:

function autoCrumbs(path: string): Crumb[] {
  const segments = path.split("/").filter(Boolean);
  const meaningful = segments.filter(
    (s) => s !== "page" && !/^\d+$/.test(s)
  );
  return meaningful.map((seg, i) => ({
    label: seg.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
    href: "/" + meaningful.slice(0, i + 1).join("/"),
  }));
}
Enter fullscreen mode Exit fullscreen mode

The filter strips the literal "page" segment and any purely numeric segments —
so /blog/page/2 produces crumbs for "Blog" but not "Page" or "2". This is the
right behaviour: pagination is a structural detail, not a meaningful content level.

Labels are formatted by replacing hyphens with spaces and capitalising each word.
"why-astro" becomes "Why Astro". It's not perfect for every slug — a post
titled "Using MDX" with slug using-mdx would render as "Using Mdx" — but it's
good enough for this site's naming conventions.

Schema.org BreadcrumbList

Search engines use BreadcrumbList structured data
to display a breadcrumb trail in search results. The markup sits inline in the
rendered HTML using itemscope and itemprop attributes:

<ol
  class="breadcrumb__list"
  itemscope
  itemtype="https://schema.org/BreadcrumbList"
>
  {items.map(({ label, href, isLast, position }) => (
    <li
      class="breadcrumb__item"
      itemprop="itemListElement"
      itemscope
      itemtype="https://schema.org/ListItem"
    >
      {!isLast && href ? (
        <a href={href} class="breadcrumb__link" itemprop="item">
          <span itemprop="name">{label}</span>
        </a>
      ) : (
        <span
          class="breadcrumb__current"
          aria-current="page"
          itemprop="name"
        >
          {label}
        </span>
      )}
      <meta itemprop="position" content={String(position)} />
    </li>
  ))}
</ol>
Enter fullscreen mode Exit fullscreen mode

The <meta itemprop="position"> tag carries the 1-based index for each crumb.
Google uses this to understand the hierarchy — it won't infer the order from
DOM order alone when using microdata.

Accessibility

The <nav> element has an aria-label="Breadcrumb" attribute to distinguish it
from other navigation landmarks on the page (the main navbar also uses <nav>):

<nav aria-label="Breadcrumb" class="breadcrumb-nav">
Enter fullscreen mode Exit fullscreen mode

The current page crumb uses aria-current="page" and is rendered as <span>
rather than <a> — it's the page the user is already on, so making it a link
would be misleading. Screen readers announce aria-current="page" explicitly,
giving users the context they need.

The complete component and its integration in PageHero.astro are in the
sourcier.uk repository.

Full code listing

---
interface Crumb {
  label: string;
  href?: string;
}

interface Props {
  crumbs: Crumb[];
  inverted?: boolean;
  noContainer?: boolean;
}

const { crumbs, inverted = false, noContainer = false } = Astro.props;
const allCrumbs = [{ label: "Home", href: "/" }, ...crumbs];

const items = allCrumbs.map((crumb, index) => ({
  ...crumb,
  isLast: index === allCrumbs.length - 1,
  position: index + 1,
}));
---

<nav
  aria-label="Breadcrumb"
  class:list={["breadcrumb-nav", { "breadcrumb-nav--inverted": inverted }]}
>
  {
    noContainer ? (
      <ol
        class="breadcrumb__list"
        itemscope
        itemtype="https://schema.org/BreadcrumbList"
      >
        {items.map(({ label, href, isLast, position }) => (
          <li
            class="breadcrumb__item"
            itemprop="itemListElement"
            itemscope
            itemtype="https://schema.org/ListItem"
          >
            {!isLast && href ? (
              <a href={href} class="breadcrumb__link" itemprop="item">
                <span itemprop="name">{label}</span>
              </a>
            ) : (
              <span
                class="breadcrumb__current"
                aria-current="page"
                itemprop="name"
              >
                {label}
              </span>
            )}
            <meta itemprop="position" content={String(position)} />
          </li>
        ))}
      </ol>
    ) : (
      <div class="container is-max-desktop">
        <ol
          class="breadcrumb__list"
          itemscope
          itemtype="https://schema.org/BreadcrumbList"
        >
          {items.map(({ label, href, isLast, position }) => (
            <li
              class="breadcrumb__item"
              itemprop="itemListElement"
              itemscope
              itemtype="https://schema.org/ListItem"
            >
              {!isLast && href ? (
                <a href={href} class="breadcrumb__link" itemprop="item">
                  <span itemprop="name">{label}</span>
                </a>
              ) : (
                <span
                  class="breadcrumb__current"
                  aria-current="page"
                  itemprop="name"
                >
                  {label}
                </span>
              )}
              <meta itemprop="position" content={String(position)} />
            </li>
          ))}
        </ol>
      </div>
    )
  }
</nav>

<style lang="scss">
  .breadcrumb-nav {
    padding: 0.6rem 1.5rem;
    background-color: var(--surface-elevated);
    border-bottom: 1px solid var(--border-subtle);

    &--inverted {
      padding: 0;
      background-color: transparent;
      border-bottom: none;
      margin-bottom: 1.25rem;

      .breadcrumb__link {
        color: var(--text-on-strong-alpha-45);

        &:hover,
        &:focus-visible {
          color: var(--accent-primary);
        }
      }

      .breadcrumb__current {
        color: var(--text-on-strong-alpha-75);
      }

      .breadcrumb__item:not(:last-child)::after {
        color: var(--text-on-strong-alpha-25);
      }
    }
  }

  .breadcrumb__list {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0;
    list-style: none;
    margin: 0;
    padding: 0;

    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.8rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
  }

  .breadcrumb__item {
    display: flex;
    align-items: center;

    &:not(:last-child)::after {
      content: "›";
      margin: 0 0.4em;
      color: var(--text-muted);
      font-weight: 400;
    }
  }

  .breadcrumb__link {
    color: var(--text-muted);
    text-decoration: none;
    transition: color 0.15s ease;

    &:hover,
    &:focus-visible {
      color: var(--accent-primary);
    }
  }

  .breadcrumb__current {
    color: var(--text-primary);
  }
</style>
Enter fullscreen mode Exit fullscreen mode

Top comments (0)