Skip to content

Commit d777954

Browse files
authored
feat(seo): emit Organization, WebSite, and BreadcrumbList JSON-LD on every docs page (#189)
Pre-this-change, every page on docs.sharpapi.io emitted zero structured data (Nextra doesn't ship any by default). Added a server-rendered StructuredData component wired into the layout + MDX page route so every page now emits: - Organization (sitewide, pointing back to sharpapi.io as parent) - WebSite (sitewide, docs-scoped) - BreadcrumbList (per-page, derived from the URL path and MDX frontmatter title — e.g. Docs → Api Reference → Odds Snapshot) Benefits: - Signals hierarchical internal linking explicitly to Google/Bing, which helps the 25 /en/* pages currently in "Discovered — not indexed" - Enables rich breadcrumb snippets in SERPs - Matches the baseline already in place on sharpapi.io Smoke-verified via `next build`: every /en/* page ships with 3 JSON-LD blocks and the breadcrumb leaf uses the MDX frontmatter title.
1 parent ecb6ebc commit d777954

3 files changed

Lines changed: 104 additions & 4 deletions

File tree

app/[lang]/[[...mdxPath]]/page.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { generateStaticParamsFor, importPage } from 'nextra/pages'
22
import { useMDXComponents } from '../../../mdx-components'
3+
import { PageBreadcrumb } from '../../../components/StructuredData'
34

45
export const generateStaticParams = generateStaticParamsFor('mdxPath')
56

@@ -30,9 +31,15 @@ export default async function Page(props) {
3031
const params = await props.params
3132
const result = await importPage(params.mdxPath, params.lang)
3233
const { default: MDXContent, toc, metadata } = result
34+
const pathname = params.mdxPath
35+
? `/${params.lang}/${params.mdxPath.join('/')}`
36+
: `/${params.lang}`
3337
return (
34-
<Wrapper toc={toc} metadata={metadata}>
35-
<MDXContent {...props} params={params} />
36-
</Wrapper>
38+
<>
39+
<PageBreadcrumb pathname={pathname} title={metadata?.title as string | undefined} />
40+
<Wrapper toc={toc} metadata={metadata}>
41+
<MDXContent {...props} params={params} />
42+
</Wrapper>
43+
</>
3744
)
3845
}

app/[lang]/layout.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Head, Search } from 'nextra/components'
33
import { getPageMap } from 'nextra/page-map'
44
import Link from 'next/link'
55
import { PostHogProvider } from '../../components/PostHogProvider'
6+
import { SiteStructuredData } from '../../components/StructuredData'
67

78
import 'nextra-theme-docs/style.css'
89
import '../../styles/globals.css'
@@ -51,7 +52,9 @@ export default async function LangLayout({ children, params }) {
5152
const { lang } = await params
5253
return (
5354
<html lang={lang} dir="ltr" suppressHydrationWarning>
54-
<Head color={{ hue: 210 }} />
55+
<Head color={{ hue: 210 }}>
56+
<SiteStructuredData />
57+
</Head>
5558
<body>
5659
<PostHogProvider>
5760
<Layout

components/StructuredData.tsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Server component — renders Schema.org JSON-LD for search engines.
3+
*
4+
* Emitted on every docs page via app/[lang]/layout.tsx:
5+
* - Organization (sitewide)
6+
* - WebSite with SearchAction (enables in-search sitelinks search box)
7+
*
8+
* BreadcrumbList is generated per-page from the pathname; see PageBreadcrumb.
9+
*/
10+
11+
const SITE_URL = 'https://docs.sharpapi.io'
12+
const MAIN_URL = 'https://sharpapi.io'
13+
14+
function jsonLd(obj: object) {
15+
return { __html: JSON.stringify(obj).replace(/</g, '\\u003c') }
16+
}
17+
18+
export function SiteStructuredData() {
19+
return (
20+
<>
21+
<script
22+
type="application/ld+json"
23+
dangerouslySetInnerHTML={jsonLd({
24+
'@context': 'https://schema.org',
25+
'@type': 'Organization',
26+
'name': 'SharpAPI',
27+
'url': MAIN_URL,
28+
'logo': `${MAIN_URL}/logo.svg`,
29+
'sameAs': [
30+
'https://github.com/Mlaz-code',
31+
],
32+
})}
33+
/>
34+
<script
35+
type="application/ld+json"
36+
dangerouslySetInnerHTML={jsonLd({
37+
'@context': 'https://schema.org',
38+
'@type': 'WebSite',
39+
'name': 'SharpAPI Docs',
40+
'url': SITE_URL,
41+
'publisher': { '@type': 'Organization', 'name': 'SharpAPI', 'url': MAIN_URL },
42+
})}
43+
/>
44+
</>
45+
)
46+
}
47+
48+
interface PageBreadcrumbProps {
49+
pathname: string
50+
title?: string
51+
}
52+
53+
export function PageBreadcrumb({ pathname, title }: PageBreadcrumbProps) {
54+
const parts = pathname.split('/').filter(Boolean)
55+
if (parts.length === 0)
56+
return null
57+
58+
// Skip the leading locale segment in the user-visible chain.
59+
// Always root the chain at "Docs" → optional subsection → leaf page.
60+
const trail = parts[0]?.length === 2 || parts[0] === 'pt-BR' ? parts.slice(1) : parts
61+
const items: Array<Record<string, unknown>> = [{
62+
'@type': 'ListItem',
63+
'position': 1,
64+
'name': 'Docs',
65+
'item': `${SITE_URL}/${parts[0]}`,
66+
}]
67+
trail.forEach((segment, i) => {
68+
const href = `${SITE_URL}/${parts[0]}/${trail.slice(0, i + 1).join('/')}`
69+
const name = i === trail.length - 1 && title
70+
? title
71+
: segment.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
72+
items.push({
73+
'@type': 'ListItem',
74+
'position': i + 2,
75+
'name': name,
76+
'item': href,
77+
})
78+
})
79+
80+
return (
81+
<script
82+
type="application/ld+json"
83+
dangerouslySetInnerHTML={jsonLd({
84+
'@context': 'https://schema.org',
85+
'@type': 'BreadcrumbList',
86+
'itemListElement': items,
87+
})}
88+
/>
89+
)
90+
}

0 commit comments

Comments
 (0)