Breadcrumb & Article Structured Data in SPAs

Breadcrumb trails and article rich results both depend on JSON-LD that a crawler can read in the rendered DOM — and in a client-rendered app, keeping two related schema blocks present and correct across route changes is where it usually breaks. This guide builds both reliably and extends JSON-LD implementation in single page apps.

Problem Statement

An article page typically wants two schema types working together: Article to describe the content itself and BreadcrumbList to describe where it sits in the site hierarchy. In a SPA both are injected by JavaScript, and both are vulnerable to the same lifecycle hazard. When the router unmounts a page, a component that created its script on mount often destroys it on unmount; the next route recreates it — but if that recreation happens on an effect that fires after the crawler has already snapshotted the rendered DOM, the structured data is momentarily or persistently absent. The two blocks can also collide: without stable identifiers, repeated navigation appends duplicates, and crawlers report conflicting nodes.

The durable pattern is to treat each schema type as a single, persistently-present node keyed by a stable @id, and to mutate its contents on route change rather than removing and recreating the element.

Two keyed schema nodes mutated in place across a route change A route change updates route data that feeds two persistent script nodes — a BreadcrumbList and an Article block, each keyed by a stable id — so both stay present in the rendered DOM the crawler reads. Route change new article data Two persistent nodes, mutated in place BreadcrumbList id: #breadcrumb Article id: #article Rendered DOM crawler reads both stable ids let each block update without recreating the element or duplicating nodes
Each schema type is one persistent node keyed by id; a route change rewrites its contents so both survive in the DOM the crawler snapshots.

Step-by-step fix

  1. Build the two payloads from route data. Construct a BreadcrumbList from the route’s ancestor trail and an Article from the content record, each carrying a stable @id anchored to the canonical URL.

    // build-schema.ts
    const ORIGIN = 'https://example.com';
    
    interface Crumb { name: string; path: string; }
    interface ArticleData { title: string; slug: string; published: string; updated: string; author: string; image: string; }
    
    export function buildBreadcrumb(trail: Crumb[]) {
      return {
        '@context': 'https://schema.org',
        '@type': 'BreadcrumbList',
        '@id': `${ORIGIN}${trail[trail.length - 1].path}#breadcrumb`,
        itemListElement: trail.map((c, i) => ({
          '@type': 'ListItem',
          position: i + 1,
          name: c.name,
          item: `${ORIGIN}${c.path}`,
        })),
      };
    }
    
    export function buildArticle(a: ArticleData) {
      const url = `${ORIGIN}/blog/${a.slug}`;
      return {
        '@context': 'https://schema.org',
        '@type': 'Article',
        '@id': `${url}#article`,
        headline: a.title,
        image: [a.image],
        datePublished: a.published,
        dateModified: a.updated,
        author: { '@type': 'Person', name: a.author },
        mainEntityOfPage: { '@type': 'WebPage', '@id': url },
      };
    }
  2. Write each payload to a persistent, id-keyed node. Look the node up by a DOM id; create it once if missing, then mutate its textContent. Mutation rather than replacement keeps the block present continuously and avoids duplicate-node warnings.

    // upsert-jsonld.ts
    export function upsertJsonLd(domId: string, payload: object): void {
      let script = document.getElementById(domId) as HTMLScriptElement | null;
      if (!script) {
        script = document.createElement('script');
        script.type = 'application/ld+json';
        script.id = domId;
        document.head.appendChild(script);
      }
      // Mutate in place — never remove and recreate on every navigation
      script.textContent = JSON.stringify(payload);
    }
  3. Drive both updates from one route-commit hook. On every navigation, rebuild and upsert both blocks together so the breadcrumb trail and article metadata always describe the same route.

    // on-route-commit.ts
    import { buildBreadcrumb, buildArticle } from './build-schema';
    import { upsertJsonLd } from './upsert-jsonld';
    
    export function syncArticleSchema(trail, article) {
      upsertJsonLd('ld-breadcrumb', buildBreadcrumb(trail));
      upsertJsonLd('ld-article', buildArticle(article));
    }
  4. Bind it to the framework’s commit phase. In React, run it in useLayoutEffect so the DOM is updated before paint; the persistent-node pattern means no cleanup that strips the block on unmount.

    // useArticleSchema.ts (React)
    import { useLayoutEffect } from 'react';
    import { syncArticleSchema } from './on-route-commit';
    
    export function useArticleSchema(trail, article) {
      useLayoutEffect(() => {
        syncArticleSchema(trail, article);
        // no removal on unmount: the next route mutates the same nodes
      }, [trail, article]);
    }
  5. Keep ids absolute and canonical. Both the breadcrumb item URLs and the article @id must be absolute and match the page’s canonical, so the structured data ties cleanly to the indexable URL rather than a parameterized variant — consistent with canonical URL management.

The BreadcrumbList positions and the Article node share the canonical URL Three ListItems number the trail from Home to Blog to the article, and the Article node anchors its id to the same canonical URL as the final breadcrumb. One numbered trail, anchored to the canonical URL Home position 1 · / Blog position 2 · /blog This article position 3 · /blog/slug Article node @id: /blog/slug#article Same canonical URL ties the last crumb and the Article together.
The final crumb and the Article node share one canonical URL, so crawlers bind the trail and the article to the same indexable page.

Validation

Confirm both blocks are present and valid in the rendered DOM, not just the source:

Four validation checks, each confirming both schema blocks DevTools querySelectorAll, an end-to-end route-change test, the Rich Results Test, and GSC URL Inspection each confirm the BreadcrumbList and Article nodes are present and valid. Each check must find both BreadcrumbList and Article Validation method BreadcrumbList Article DevTools querySelectorAll End-to-end test across navigation Rich Results Test GSC URL Inspection render
Validation only passes when every method finds both the BreadcrumbList and the Article node in the rendered DOM, not just one.
  • Inspect the rendered DOM. In DevTools, document.querySelectorAll('script[type="application/ld+json"]') should return the breadcrumb and article nodes — exactly one of each — after navigating between articles.

  • Assert both types survive a route change in an end-to-end test.

    // schema.spec.ts (Playwright)
    import { test, expect } from '@playwright/test';
    
    test('breadcrumb and article JSON-LD persist across navigation', async ({ page }) => {
      await page.goto('/blog/first-post');
      await page.click('a[href="/blog/second-post"]');
      const types = await page.evaluate(() =>
        [...document.querySelectorAll('script[type="application/ld+json"]')]
          .map((s) => JSON.parse(s.textContent!)['@type'])
      );
      expect(types).toContain('BreadcrumbList');
      expect(types).toContain('Article');
    });
  • Run each URL through the Rich Results Test and confirm both Breadcrumbs and Article are detected with no errors.

  • Check GSC URL Inspection’s rendered HTML for both script blocks, and watch the Breadcrumbs enhancement report for valid items over time.

Common Pitfalls

  • Removing the script on unmount. A cleanup that deletes the node leaves the DOM without structured data between routes; mutate a persistent node instead, the same principle behind fixing missing JSON-LD after hydration.
  • Duplicate blocks from missing ids. Appending a fresh script per navigation accumulates duplicates; key each type by a stable DOM id and upsert.
  • Relative breadcrumb URLs. Item URLs must be absolute and canonical or the trail resolves ambiguously.
  • Breadcrumb trail not matching the visible path. The BreadcrumbList should mirror the on-page breadcrumb the user sees; a mismatch risks the rich result being dropped.
  • Stale dateModified. Carry the real content update date from the record, not the render time, so the article’s freshness signal stays truthful.
Removing the node on unmount leaves a gap; mutating it keeps schema present Across a route change, removing the script on unmount leaves a window with no structured data that a crawler snapshot can catch, while mutating a persistent node keeps the schema present throughout. Between route A and route B, is the schema ever absent? Remove on unmount Article present (route A) removed — empty re-injected (route B) crawler snapshot → no schema ✗ Mutate persistent node Article node present throughout — textContent swapped in place crawler snapshot → schema found ✓
The remove-and-recreate lane exposes an empty window a crawler can catch; mutating one persistent node never does.

Frequently Asked Questions

Can BreadcrumbList and Article JSON-LD live in the same SPA page?

Yes. Emit them as two separate script blocks or as a single graph array, each with its own stable @id, and update both on route change. Keeping distinct ids lets you mutate one without disturbing the other and avoids duplicate-node warnings when navigation reuses the document.

Why does my Article structured data disappear after client-side navigation?

A route change that unmounts the component often removes its injected script, and if the next route re-injects on an effect that runs after the crawler snapshot, the DOM is momentarily without structured data. Mutate a persistent script node keyed by @id rather than removing and recreating it.

Do breadcrumb URLs in JSON-LD need to be absolute?

Yes. Each item URL in a BreadcrumbList should be an absolute, canonical URL so crawlers can resolve the trail unambiguously. Relative paths and parameterized variants weaken the association between the breadcrumb and the page it describes.

← Back to JSON-LD Implementation in Single Page Apps