Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion src/components/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand All @@ -22,6 +23,7 @@ type BreadcrumbsProps = {
export function Breadcrumbs({
section,
sectionTo,
items,
headings,
tocHiddenBreakpoint = 'lg',
}: BreadcrumbsProps) {
Expand All @@ -36,7 +38,26 @@ export function Breadcrumbs({
return (
<div className="flex flex-col">
<div className="flex items-center justify-between gap-4 text-sm text-gray-500 dark:text-gray-400">
{sectionTo ? (
{items ? (
<nav aria-label="Breadcrumb" className="min-w-0">
<ol className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
{items.map((item, index) => (
<li key={item.to} className="min-w-0 flex gap-2">
{index > 0 && <span aria-hidden="true">/</span>}
{index === items.length - 1 ? (
<span aria-current="page" className="break-words">
{item.label}
</span>
) : (
<Link to={item.to} className="hover:underline">
{item.label}
</Link>
)}
</li>
))}
</ol>
</nav>
) : sectionTo ? (
<Link
to={sectionTo}
className="whitespace-nowrap hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
Expand Down
12 changes: 10 additions & 2 deletions src/routes/blog.$.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import { findMaintainerByAuthorName } from '~/utils/authors'
import { CoverFallback } from '~/components/CoverFallback'
import { fetchBlogPost } from '~/utils/blog.functions'
import { parseBlogMarkdown } from '~/utils/blogMarkdown'
import { getBlogPostHead, getBlogSocialImageUrl } from '~/utils/blog-post-seo'
import {
getBlogBreadcrumbItems,
getBlogPostHead,
getBlogSocialImageUrl,
} from '~/utils/blog-post-seo'

export const Route = createFileRoute('/blog/$')({
staleTime: Infinity,
Expand Down Expand Up @@ -51,6 +55,7 @@ function BlogPost() {
published,
filePath,
title,
slug: postSlug,
headerImage,
headerVideo,
headerVideoCaptions,
Expand Down Expand Up @@ -140,7 +145,10 @@ function BlogPost() {
>
<Breadcrumbs
section="Blog"
sectionTo="/blog"
items={getBlogBreadcrumbItems({
title,
slug: postSlug,
})}
headings={isTocVisible ? headings : undefined}
tocHiddenBreakpoint="xl"
/>
Expand Down
17 changes: 17 additions & 0 deletions src/utils/blog-post-seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,30 @@ 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}`)

return {
'@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`,
Expand Down
34 changes: 34 additions & 0 deletions tests/blog-post-seo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '../src/utils/blog-format'
import {
type BlogPostHeadData,
getBlogBreadcrumbItems,
getBlogPostHead,
getBlogSocialImageUrl,
} from '../src/utils/blog-post-seo'
Expand Down Expand Up @@ -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 & <Types>',
}
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',
},
],
})
})
Loading