BreadcrumbList JSON-LD in the Next.js App Router

BreadcrumbList still earns a desktop rich result (Google dropped mobile breadcrumbs in January 2025). Google's actual rules, a working App Router component, and the mistakes that matter.

· Updated 10 min read

On desktop, Google can replace the URL path under a search result with a readable trail such as example.com › Blog › Guides. BreadcrumbList markup is what feeds it. On phones, Google has shown only the domain since January 2025, so the visible effect is now a desktop one. Google's breadcrumb documentation also says the markup helps it "understand and categorize the information on the page".

Below: the rules Google actually publishes (they're looser than most guides suggest), a component for the Next.js App Router that keeps the markup and the visible trail in sync, and the handful of mistakes worth checking for.

Google's rules, as written

  • At least two ListItems. A one-item list isn't eligible.
  • position is an Integer, and 1 marks the start of the trail.
  • Follow a typical user path, not the URL. Google recommends "breadcrumbs that represent a typical user path to a page, instead of mirroring the URL structure."
  • The homepage is optional, and so is the page itself. In Google's words: "It is not required to include a breadcrumb ListItem for the top level path (your site's domain or host name), nor for the page itself."
  • item is optional on the last breadcrumb. If it's missing, Google uses the URL of the page containing the markup.
  • Several trails are allowed when a page can be reached by more than one path.

The general structured data guidelines add one more: mark up content that users can see. In practice that means the JSON-LD and the breadcrumb on the page should describe the same trail.

Starting at Home and ending with the current page is a sensible default. It just isn't a requirement, and a trail that reflects how people browse your site (Blog › Guides › this post) is closer to what Google asks for than one that copies the folder structure.

The JSON

{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "Home",
      "item": "https://example.com/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "Guides",
      "item": "https://example.com/guides"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "BreadcrumbList in Next.js"
    }
  ]
}

The last item has no item URL, which Google accepts. Including it is also fine.

When a page genuinely sits in two places, publish two trails as an array in one script tag. Google's own documentation shows this pattern:

[
  {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    "itemListElement": [
      { "@type": "ListItem", "position": 1, "name": "Guides", "item": "https://example.com/guides" },
      { "@type": "ListItem", "position": 2, "name": "Structured data", "item": "https://example.com/guides/structured-data" },
      { "@type": "ListItem", "position": 3, "name": "BreadcrumbList in Next.js" }
    ]
  },
  {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    "itemListElement": [
      { "@type": "ListItem", "position": 1, "name": "Tutorials", "item": "https://example.com/tutorials" },
      { "@type": "ListItem", "position": 2, "name": "Next.js", "item": "https://example.com/tutorials/nextjs" },
      { "@type": "ListItem", "position": 3, "name": "BreadcrumbList in Next.js" }
    ]
  }
]
WORKSposition: 1Home (optional)position: 2Guidesposition: 3This page (item optional)AVOIDposition: 0Array index, forgot the +1position: "1"String where Integer is expected1 → 3 → 4Gap in the sequence

A BreadcrumbList component for the App Router

The approach: one array of crumbs per page, used to render both the visible <nav> and the JSON-LD. If they come from the same data, they can't drift apart.

// components/Breadcrumbs.tsx (a Server Component)
import Link from "next/link";

const SITE_URL = "https://example.com";

export type Crumb = { name: string; path?: string };

export function Breadcrumbs({ crumbs }: { crumbs: Crumb[] }) {
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    itemListElement: crumbs.map((crumb, i) => ({
      "@type": "ListItem",
      position: i + 1,
      name: crumb.name,
      ...(crumb.path ? { item: new URL(crumb.path, SITE_URL).toString() } : {}),
    })),
  };

  return (
    <>
      {crumbs.length >= 2 && (
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{
            __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
          }}
        />
      )}
      <nav aria-label="Breadcrumb">
        <ol>
          {crumbs.map((crumb) => (
            <li key={crumb.name}>
              {crumb.path ? (
                <Link href={crumb.path}>{crumb.name}</Link>
              ) : (
                <span aria-current="page">{crumb.name}</span>
              )}
            </li>
          ))}
        </ol>
      </nav>
    </>
  );
}

Then in the route:

// app/guides/[slug]/page.tsx
import { Breadcrumbs } from "@/components/Breadcrumbs";
import { getGuide } from "@/lib/guides";

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const guide = await getGuide(slug);

  return (
    <article>
      <Breadcrumbs
        crumbs={[
          { name: "Home", path: "/" },
          { name: "Guides", path: "/guides" },
          { name: guide.title },
        ]}
      />
      <h1>{guide.title}</h1>
      {/* … */}
    </article>
  );
}

What each part is doing:

  • position: i + 1. Array indexes start at 0 and breadcrumb positions start at 1. This is where most numbering bugs come from.
  • new URL(crumb.path, SITE_URL) turns relative paths into the absolute URLs the markup needs, while the <Link> keeps using relative ones.
  • The .replace(/</g, "\\u003c"). JSON.stringify doesn't escape </script>, so a title containing it would close the script tag early. The Next.js JSON-LD guide recommends exactly this replacement; the output is still valid JSON.
  • The crumbs.length >= 2 guard. Google needs two items, so a single crumb renders the nav but no markup.
  • No item on the last crumb. It matches the visible trail, where the current page isn't a link.
  • It's a Server Component. The script is in the HTML the server sends. Google can process JSON-LD injected by JavaScript, but Vercel's crawler study found GPTBot, ClaudeBot and PerplexityBot don't run JavaScript, so building it in a useEffect hides it from them.

Use labels written for people. Deriving names from slugs gives you trails like "aeo-tools"; a title or label field in your content source avoids that. If a page lives in two sections, render two Breadcrumbs instances or extend the component to accept several trails and output the array shown earlier.

WordPress, Webflow and headless setups

On WordPress, Yoast and Rank Math both include a BreadcrumbList in their schema output once breadcrumbs are enabled. Run only one of them, or you can end up with the same trail twice. On Webflow or a headless CMS, the pattern is the one above: build the trail on the server from CMS fields rather than in client-side script.

Mistakes worth checking for

  1. Markup and visible breadcrumb disagree. The JSON-LD says Home › Blog › Post while the page shows Home › Post. Generate both from the same data.
  2. The same trail twice. Two plugins, or a plugin plus a theme, each emitting an identical BreadcrumbList is noise; remove one. Intentional, different trails are fine.
  3. A one-item list. Not eligible. Two items is the minimum.
  4. Positions starting at 0 or written as strings. Use integers from 1.
  5. URLs in the trail that 404. They send users and crawlers to dead pages; check them after any URL restructure.
  6. Markup that exists only after hydration. Check view-source, not the browser inspector.

Checking that it works

Run the live URL through Google's Rich Results Test. Using the URL rather than pasted code means Google fetches and renders the page; "Breadcrumbs" should appear as a detected item with no errors. After Google has recrawled the page, Search Console's Breadcrumbs report (under Enhancements) shows valid and invalid items across the site.

To see the result in search, look on desktop. A phone will show only the domain, which is expected since January 2025, not a sign the markup failed.

As for AI search: Google's guide to generative AI features says there's "no special schema.org markup you need to add". BreadcrumbList is for the desktop result and for clear site structure. Nobody has shown it affects AI citations.

Next step: test one template

Breadcrumbs come from templates, so one check covers every page that uses the template. Paste a URL from each template type (post, category, product) into the tool below, fix what it reports, then re-test one URL in the Rich Results Test.

AI
Free tool · No signup
Free Schema Validator
Paste any URL → find the structured data your page is missing, with ready-to-paste JSON-LD fixes.
Check your schema →

This blog generates its own trail the same way (Home › Blog › post title) from one function. The other markup that belongs on every post is the author, covered in Article schema with author.

FAQ

Can I add BreadcrumbList if the page shows no breadcrumb?
You can, but it goes against Google's general rule of marking up content users can see. Add a visible breadcrumb to the template first, then generate the markup from the same data. The visible trail also helps readers move up a level, which is the reason breadcrumbs exist in the first place.
Can BreadcrumbList markup hurt rankings?
There's no documented ranking penalty for a flawed trail; an invalid one simply isn't shown. The practical risks are dead URLs inside the trail and a trail that contradicts your navigation. Both are worth fixing for users, whatever the markup does.

Sources cited in this piece

Updated October 1, 2026: rewritten for Google's current rules (desktop-only display since January 2025, optional homepage and last URL, multiple trails allowed) and refocused on a Next.js App Router implementation.