diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx index 6a5974f66..8ee9a326b 100644 --- a/src/components/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs.tsx @@ -12,6 +12,7 @@ import { type BreadcrumbsProps = { /** Section label (e.g., "Getting Started", "Blog") */ section: string + items?: Array<{ label: string; to: string }> /** Optional link for the section */ sectionTo?: string headings?: MarkdownHeading[] @@ -22,6 +23,7 @@ type BreadcrumbsProps = { export function Breadcrumbs({ section, sectionTo, + items, headings, tocHiddenBreakpoint = 'lg', }: BreadcrumbsProps) { @@ -36,7 +38,26 @@ export function Breadcrumbs({ return (
- {sectionTo ? ( + {items ? ( + + ) : sectionTo ? ( diff --git a/src/utils/blog-post-seo.ts b/src/utils/blog-post-seo.ts index caf61b872..405935d3f 100644 --- a/src/utils/blog-post-seo.ts +++ b/src/utils/blog-post-seo.ts @@ -37,6 +37,13 @@ export function getBlogSocialImageUrl(headerImage: string | undefined) { }) } +export function getBlogBreadcrumbItems(post: { title: string; slug: string }) { + return [ + { label: 'Blog', to: '/blog' }, + { label: post.title, to: `/blog/${post.slug}` }, + ] +} + export function getBlogPostingJsonLd(post: BlogPostHeadData) { const pageUrl = canonicalUrl(`/blog/${post.slug}`) @@ -44,6 +51,16 @@ export function getBlogPostingJsonLd(post: BlogPostHeadData) { '@context': 'https://schema.org', '@graph': [ getTanStackOrganizationJsonLd(), + { + '@type': 'BreadcrumbList', + '@id': `${pageUrl}#breadcrumb`, + itemListElement: getBlogBreadcrumbItems(post).map((item, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: item.label, + item: canonicalUrl(item.to), + })), + }, { '@type': 'BlogPosting' as const, '@id': `${pageUrl}#blog-post`, diff --git a/tests/blog-post-seo.test.ts b/tests/blog-post-seo.test.ts index 9fe2d9b0e..44dcb3680 100644 --- a/tests/blog-post-seo.test.ts +++ b/tests/blog-post-seo.test.ts @@ -8,6 +8,7 @@ import { } from '../src/utils/blog-format' import { type BlogPostHeadData, + getBlogBreadcrumbItems, getBlogPostHead, getBlogSocialImageUrl, } from '../src/utils/blog-post-seo' @@ -235,3 +236,36 @@ test('known modification dates reach frontmatter, JSON-LD, and article metadata' true, ) }) + +test('blog breadcrumbs use the visible trail with canonical ordered URLs', () => { + const post = { + ...basePost, + slug: '2026/typed-routing', + title: 'Routing & ', + } + const trail = getBlogBreadcrumbItems(post) + assert.deepEqual(trail, [ + { label: 'Blog', to: '/blog' }, + { label: post.title, to: '/blog/2026/typed-routing' }, + ]) + const graph = getJsonLd(getBlogPostHead(post))['@graph'] + const breadcrumb = graph.find((node) => node['@type'] === 'BreadcrumbList') + assert.deepEqual(breadcrumb, { + '@type': 'BreadcrumbList', + '@id': 'https://tanstack.com/blog/2026/typed-routing#breadcrumb', + itemListElement: [ + { + '@type': 'ListItem', + position: 1, + name: 'Blog', + item: 'https://tanstack.com/blog', + }, + { + '@type': 'ListItem', + position: 2, + name: post.title, + item: 'https://tanstack.com/blog/2026/typed-routing', + }, + ], + }) +})