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.
Step-by-step fix
-
Build the two payloads from route data. Construct a
BreadcrumbListfrom the route’s ancestor trail and anArticlefrom the content record, each carrying a stable@idanchored 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 }, }; } -
Write each payload to a persistent, id-keyed node. Look the node up by a DOM
id; create it once if missing, then mutate itstextContent. 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); } -
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)); } -
Bind it to the framework’s commit phase. In React, run it in
useLayoutEffectso 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]); } -
Keep ids absolute and canonical. Both the breadcrumb item URLs and the article
@idmust 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.
Validation
Confirm both blocks are present and valid in the rendered DOM, not just the source:
-
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
BreadcrumbListshould 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.
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.
Related
- JSON-LD Implementation in Single Page Apps — the injection, cleanup, and validation baseline this builds on.
- Fixing missing JSON-LD after client-side hydration — recover structured data stripped during hydration.
- Canonical URL Management in SPAs — anchor schema ids to the canonical URL.