diff --git a/apps/web/app/[locale]/builders-hub/ideas/[slug]/page.tsx b/apps/web/app/[locale]/builders-hub/ideas/[slug]/page.tsx
index e68a166b68..0a55f48e7c 100644
--- a/apps/web/app/[locale]/builders-hub/ideas/[slug]/page.tsx
+++ b/apps/web/app/[locale]/builders-hub/ideas/[slug]/page.tsx
@@ -5,16 +5,21 @@ import {
ContentNotFoundError,
getAllIdeas,
getAllRfps,
+ getBuilderHubListingSettings,
getIdeaBySlug,
+ getPageCopy,
} from '@repo/content/loaders'
import { BuildersHubDetailLayout } from '@/components/sections/builders-hub/builders-hub-detail-layout'
import { RelatedLinksList } from '@/components/sections/builders-hub/related-links-list'
+import { JsonLd } from '@/components/seo/json-ld'
import { ROUTES } from '@/constants/routes'
+import siteConfig from '@/constants/site-config'
import { formatDateLong } from '@/lib/dates'
import { createDefaultMetadata } from '@/lib/metadata'
import { resolveLocale, type LocaleSlugParams } from '@/lib/route-params'
import { formatReward } from '@/lib/reward'
+import { createBreadcrumbListJsonLd } from '@/lib/structured-data'
export async function generateStaticParams() {
const params: Array<{ locale: string; slug: string }> = []
@@ -60,6 +65,11 @@ export default async function IdeaDetailPage({ params }: LocaleSlugParams) {
}
if (idea.status !== 'published') notFound()
+ const [buildersHub, listingSettings] = await Promise.all([
+ getPageCopy(ROUTES.buildersHub, locale),
+ getBuilderHubListingSettings({ page: 'ideas', locale }),
+ ])
+
const submitter = idea.submitter.name
? `${idea.submitter.name} (@${idea.submitter.handle})`
: `@${idea.submitter.handle}`
@@ -90,30 +100,46 @@ export default async function IdeaDetailPage({ params }: LocaleSlugParams) {
}
return (
-
- }
- />
+ <>
+
+
+ }
+ />
+ >
)
}
diff --git a/apps/web/app/[locale]/builders-hub/ideas/page.tsx b/apps/web/app/[locale]/builders-hub/ideas/page.tsx
index dc992f071b..e789dca51a 100644
--- a/apps/web/app/[locale]/builders-hub/ideas/page.tsx
+++ b/apps/web/app/[locale]/builders-hub/ideas/page.tsx
@@ -1,12 +1,16 @@
import {
getAllIdeas,
getBuilderHubListingSettings,
+ getPageCopy,
} from '@repo/content/loaders'
import { isActiveLocale } from '@repo/content/locales'
import { BuildersHubListingClient } from '@/components/sections/builders-hub/builders-hub-listing-client'
+import { JsonLd } from '@/components/seo/json-ld'
import { ROUTES } from '@/constants/routes'
+import siteConfig from '@/constants/site-config'
import { createDefaultMetadata } from '@/lib/metadata'
+import { createBreadcrumbListJsonLd } from '@/lib/structured-data'
const ROUTE = ROUTES.ideas
@@ -38,16 +42,32 @@ export default async function IdeasPage({
throw new Error(`IdeasPage received non-active locale "${locale}"`)
}
- const [settings, allIdeas] = await Promise.all([
+ const [settings, allIdeas, buildersHub] = await Promise.all([
getBuilderHubListingSettings({ page: 'ideas', locale }),
getAllIdeas({ locale, status: 'published' }),
+ getPageCopy(ROUTES.buildersHub, locale),
])
return (
-
+ <>
+
+
+ >
)
}
diff --git a/apps/web/app/[locale]/builders-hub/rfps/[slug]/page.tsx b/apps/web/app/[locale]/builders-hub/rfps/[slug]/page.tsx
index ddb341a469..92caa0a905 100644
--- a/apps/web/app/[locale]/builders-hub/rfps/[slug]/page.tsx
+++ b/apps/web/app/[locale]/builders-hub/rfps/[slug]/page.tsx
@@ -1,11 +1,17 @@
import { notFound } from 'next/navigation'
import { getActiveLocales } from '@repo/content/locales'
+import {
+ getBuilderHubListingSettings,
+ getPageCopy,
+} from '@repo/content/loaders'
import { BuildersHubDetailLayout } from '@/components/sections/builders-hub/builders-hub-detail-layout'
+import { JsonLd } from '@/components/seo/json-ld'
import { LegalMarkdown } from '@/components/sections/shared/legal-markdown'
import { Button } from '@/components/ui'
import { ROUTES } from '@/constants/routes'
+import siteConfig from '@/constants/site-config'
import { createDefaultMetadata } from '@/lib/metadata'
import { resolveLocale, type LocaleSlugParams } from '@/lib/route-params'
import {
@@ -14,6 +20,7 @@ import {
fetchGithubRfps,
stripLeadingHeading,
} from '@/lib/rfps-github'
+import { createBreadcrumbListJsonLd } from '@/lib/structured-data'
export async function generateStaticParams() {
const rfps = await fetchGithubRfps()
@@ -45,12 +52,17 @@ export async function generateMetadata({ params }: LocaleSlugParams) {
}
export default async function RfpDetailPage({ params }: LocaleSlugParams) {
- await resolveLocale(params, 'RfpDetailPage')
+ const locale = await resolveLocale(params, 'RfpDetailPage')
const { slug } = await params
const rfp = await fetchGithubRfpBySlug(slug)
if (!rfp) notFound()
+ const [buildersHub, listingSettings] = await Promise.all([
+ getPageCopy(ROUTES.buildersHub, locale),
+ getBuilderHubListingSettings({ page: 'rfps', locale }),
+ ])
+
const meta = [
{ label: 'Status', value: rfp.status },
rfp.category ? { label: 'Category', value: rfp.category } : null,
@@ -58,24 +70,40 @@ export default async function RfpDetailPage({ params }: LocaleSlugParams) {
].filter((x): x is { label: string; value: string } => Boolean(x))
return (
- }
- primaryCta={{
- label: 'Apply',
- href: RFP_APPLY_URL,
- external: true,
- }}
- meta={meta}
- footer={
-
- }
- />
+ <>
+
+ }
+ primaryCta={{
+ label: 'Apply',
+ href: RFP_APPLY_URL,
+ external: true,
+ }}
+ meta={meta}
+ footer={
+
+ }
+ />
+ >
)
}
diff --git a/apps/web/app/[locale]/builders-hub/rfps/page.tsx b/apps/web/app/[locale]/builders-hub/rfps/page.tsx
index d90346947e..2c196e9ca3 100644
--- a/apps/web/app/[locale]/builders-hub/rfps/page.tsx
+++ b/apps/web/app/[locale]/builders-hub/rfps/page.tsx
@@ -1,10 +1,16 @@
-import { getBuilderHubListingSettings } from '@repo/content/loaders'
+import {
+ getBuilderHubListingSettings,
+ getPageCopy,
+} from '@repo/content/loaders'
import { isActiveLocale } from '@repo/content/locales'
import { BuildersHubListingClient } from '@/components/sections/builders-hub/builders-hub-listing-client'
+import { JsonLd } from '@/components/seo/json-ld'
import { ROUTES } from '@/constants/routes'
+import siteConfig from '@/constants/site-config'
import { createDefaultMetadata } from '@/lib/metadata'
import { fetchGithubRfps } from '@/lib/rfps-github'
+import { createBreadcrumbListJsonLd } from '@/lib/structured-data'
const ROUTE = ROUTES.rfps
@@ -36,12 +42,32 @@ export default async function RfpsPage({
throw new Error(`RfpsPage received non-active locale "${locale}"`)
}
- const [settings, allRfps] = await Promise.all([
+ const [settings, allRfps, buildersHub] = await Promise.all([
getBuilderHubListingSettings({ page: 'rfps', locale }),
fetchGithubRfps(),
+ getPageCopy(ROUTES.buildersHub, locale),
])
return (
-
+ <>
+
+
+ >
)
}
diff --git a/apps/web/app/[locale]/field-guide/[slug]/page.tsx b/apps/web/app/[locale]/field-guide/[slug]/page.tsx
index ac58b25ba6..14792dfb6e 100644
--- a/apps/web/app/[locale]/field-guide/[slug]/page.tsx
+++ b/apps/web/app/[locale]/field-guide/[slug]/page.tsx
@@ -9,9 +9,12 @@ import {
import { isActiveLocale } from '@repo/content/locales'
import { FieldGuidePageView } from '@/components/sections/field-guide'
+import { JsonLd } from '@/components/seo/json-ld'
import { ROUTES } from '@/constants/routes'
+import siteConfig from '@/constants/site-config'
import { routing } from '@/i18n/routing'
import { createDefaultMetadata } from '@/lib/metadata'
+import { createBreadcrumbListJsonLd } from '@/lib/structured-data'
const INDEX_SLUG = 'index'
@@ -73,7 +76,26 @@ export default async function FieldGuideChapterPage({
getFieldGuideManifest(locale),
getFieldGuideChapter(locale, slug),
])
- return
+ const item = flattenFieldGuideItems(manifest).find(
+ (chapter) => chapter.slug === slug
+ )
+ if (!item) notFound()
+
+ return (
+ <>
+
+
+ >
+ )
} catch (error) {
if (error instanceof ContentNotFoundError) notFound()
throw error
diff --git a/apps/web/app/[locale]/page.tsx b/apps/web/app/[locale]/page.tsx
index 7a43ffdbf2..8d8f07b9ef 100644
--- a/apps/web/app/[locale]/page.tsx
+++ b/apps/web/app/[locale]/page.tsx
@@ -16,6 +16,7 @@ import type {
import AboutSection from '@/components/sections/home/about-section'
import BuilderPortalSection from '@/components/sections/home/builder-portal-section'
import DecideSection from '@/components/sections/home/decide-section'
+import { JsonLd } from '@/components/seo/json-ld'
import FeatureCardsSection from '@/components/sections/shared/feature-cards-section'
import HeroSectionView from '@/components/sections/shared/hero-section'
import BlogSection from '@/components/sections/home/blog-section'
@@ -28,6 +29,7 @@ import { EXTERNAL_URLS, ROUTES } from '@/constants/routes'
import { createPageMetadata } from '@/lib/page-metadata'
import { createSectionFinder } from '@/lib/page-sections'
import { getLatestBlogArticles } from '@/lib/blog-engine'
+import { createOrganizationJsonLd } from '@/lib/structured-data'
import { getSocialProofStats } from '@/lib/social-proof-stats'
import { getWinnableIssuesCount } from '@/lib/winnable-issues'
@@ -112,6 +114,7 @@ export default async function HomePage({
return (
<>
+
+
diff --git a/apps/web/app/[locale]/technology-stack/messaging/page.tsx b/apps/web/app/[locale]/technology-stack/messaging/page.tsx
index 834d9113dd..e87f694cc5 100644
--- a/apps/web/app/[locale]/technology-stack/messaging/page.tsx
+++ b/apps/web/app/[locale]/technology-stack/messaging/page.tsx
@@ -11,6 +11,7 @@ import MessagingCaseStudies from '@/components/sections/messaging/messaging-case
import MessagingHero from '@/components/sections/messaging/messaging-hero'
import MessagingIntro from '@/components/sections/messaging/messaging-intro'
import MessagingTechStack from '@/components/sections/messaging/messaging-tech-stack'
+import { JsonLd } from '@/components/seo/json-ld'
import TechStackBuilderCta from '@/components/sections/shared/tech-stack-builder-cta'
import {
TechStackDetailPage,
@@ -18,10 +19,12 @@ import {
} from '@/components/sections/shared/tech-stack-detail-layout'
import TechStackRelatedArticles from '@/components/sections/shared/tech-stack-related-articles'
import { ROUTES } from '@/constants/routes'
+import siteConfig from '@/constants/site-config'
import { createPageMetadata } from '@/lib/page-metadata'
import { createSectionFinder } from '@/lib/page-sections'
import { getLatestBlogArticles } from '@/lib/blog-engine'
import { TECH_STACK_RELATED_ARTICLE_TAGS } from '@/lib/tech-stack-related-articles'
+import { createBreadcrumbListJsonLd } from '@/lib/structured-data'
const ROUTE = ROUTES.messaging
@@ -91,6 +94,16 @@ export default async function MessagingPage({
return (
+
diff --git a/apps/web/app/[locale]/technology-stack/networking/page.tsx b/apps/web/app/[locale]/technology-stack/networking/page.tsx
index 37613f9733..b9fa32b41e 100644
--- a/apps/web/app/[locale]/technology-stack/networking/page.tsx
+++ b/apps/web/app/[locale]/technology-stack/networking/page.tsx
@@ -10,6 +10,7 @@ import type {
import NetworkingFeatures from '@/components/sections/networking/networking-features'
import NetworkingHero from '@/components/sections/networking/networking-hero'
import NetworkingIntro from '@/components/sections/networking/networking-intro'
+import { JsonLd } from '@/components/seo/json-ld'
import TechStackBuilderCta from '@/components/sections/shared/tech-stack-builder-cta'
import {
TechStackDetailPage,
@@ -19,10 +20,12 @@ import TechStackRelatedArticles from '@/components/sections/shared/tech-stack-re
import NetworkingTechStack from '@/components/sections/networking/networking-tech-stack'
import { ROUTES } from '@/constants/routes'
+import siteConfig from '@/constants/site-config'
import { createPageMetadata } from '@/lib/page-metadata'
import { createSectionFinder } from '@/lib/page-sections'
import { getLatestBlogArticles } from '@/lib/blog-engine'
import { TECH_STACK_RELATED_ARTICLE_TAGS } from '@/lib/tech-stack-related-articles'
+import { createBreadcrumbListJsonLd } from '@/lib/structured-data'
const ROUTE = ROUTES.networking
@@ -74,6 +77,16 @@ export default async function NetworkingPage({
return (
+
diff --git a/apps/web/app/[locale]/technology-stack/storage/page.tsx b/apps/web/app/[locale]/technology-stack/storage/page.tsx
index 3e8e33791f..19217e8bda 100644
--- a/apps/web/app/[locale]/technology-stack/storage/page.tsx
+++ b/apps/web/app/[locale]/technology-stack/storage/page.tsx
@@ -11,6 +11,7 @@ import StorageHero from '@/components/sections/storage/storage-hero'
import StorageAccess from '@/components/sections/storage/storage-access'
import StorageMain from '@/components/sections/storage/storage-main'
import StorageTechStack from '@/components/sections/storage/storage-tech-stack'
+import { JsonLd } from '@/components/seo/json-ld'
// Temporarily hidden — may be reused later. Do not delete.
// import StorageUseCases from '@/components/sections/storage/storage-use-cases'
import TechStackBuilderCta from '@/components/sections/shared/tech-stack-builder-cta'
@@ -21,10 +22,12 @@ import {
import TechStackRelatedArticles from '@/components/sections/shared/tech-stack-related-articles'
import { ROUTES } from '@/constants/routes'
+import siteConfig from '@/constants/site-config'
import { createPageMetadata } from '@/lib/page-metadata'
import { createSectionFinder } from '@/lib/page-sections'
import { getLatestBlogArticles } from '@/lib/blog-engine'
import { TECH_STACK_RELATED_ARTICLE_TAGS } from '@/lib/tech-stack-related-articles'
+import { createBreadcrumbListJsonLd } from '@/lib/structured-data'
const ROUTE = ROUTES.storage
@@ -89,6 +92,16 @@ export default async function StoragePage({
return (
+
diff --git a/apps/web/components/seo/json-ld.tsx b/apps/web/components/seo/json-ld.tsx
new file mode 100644
index 0000000000..904e003c7d
--- /dev/null
+++ b/apps/web/components/seo/json-ld.tsx
@@ -0,0 +1,16 @@
+import type { JsonLdObject } from '@/lib/structured-data'
+
+type Props = {
+ data: JsonLdObject
+}
+
+export function JsonLd({ data }: Props) {
+ const json = JSON.stringify(data).replace(/
+ )
+}
diff --git a/apps/web/lib/__tests__/structured-data.test.tsx b/apps/web/lib/__tests__/structured-data.test.tsx
new file mode 100644
index 0000000000..7468fd84b0
--- /dev/null
+++ b/apps/web/lib/__tests__/structured-data.test.tsx
@@ -0,0 +1,73 @@
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it } from 'vitest'
+
+import { JsonLd } from '@/components/seo/json-ld'
+import {
+ createBreadcrumbListJsonLd,
+ createOrganizationJsonLd,
+} from '@/lib/structured-data'
+
+const BASE_URL = 'https://logos.co'
+
+describe('structured data', () => {
+ it('builds the canonical Logos organisation entity', () => {
+ expect(createOrganizationJsonLd()).toMatchObject({
+ '@context': 'https://schema.org',
+ '@type': 'Organization',
+ '@id': `${BASE_URL}/#organization`,
+ name: 'Logos',
+ url: BASE_URL,
+ logo: `${BASE_URL}/apple-touch-icon.png`,
+ })
+ })
+
+ it('builds ordered canonical breadcrumb URLs', () => {
+ expect(
+ createBreadcrumbListJsonLd(
+ [
+ { name: 'Logos', path: '/' },
+ { name: 'Technology Stack', path: '/technology-stack' },
+ { name: 'Storage', path: '/technology-stack/storage' },
+ ],
+ 'en'
+ )
+ ).toMatchObject({
+ '@type': 'BreadcrumbList',
+ itemListElement: [
+ {
+ '@type': 'ListItem',
+ position: 1,
+ name: 'Logos',
+ item: `${BASE_URL}/`,
+ },
+ {
+ '@type': 'ListItem',
+ position: 2,
+ name: 'Technology Stack',
+ item: `${BASE_URL}/technology-stack`,
+ },
+ {
+ '@type': 'ListItem',
+ position: 3,
+ name: 'Storage',
+ item: `${BASE_URL}/technology-stack/storage`,
+ },
+ ],
+ })
+ })
+
+ it('escapes markup that could terminate the JSON-LD script', () => {
+ const html = renderToStaticMarkup(
+ ',
+ }}
+ />
+ )
+
+ expect(html).not.toContain('')
+ expect(html).toContain('\\u003c/script>')
+ })
+})
diff --git a/apps/web/lib/structured-data.ts b/apps/web/lib/structured-data.ts
new file mode 100644
index 0000000000..7d2eed8dd0
--- /dev/null
+++ b/apps/web/lib/structured-data.ts
@@ -0,0 +1,54 @@
+import siteConfig from '@/constants/site-config'
+import { absoluteUrl } from '@/lib/metadata'
+
+import siteSettings from '../../../content/site/en/settings.json'
+
+type JsonLdPrimitive = boolean | null | number | string
+type JsonLdValue =
+ | JsonLdPrimitive
+ | JsonLdObject
+ | ReadonlyArray
+
+export interface JsonLdObject {
+ readonly [key: string]: JsonLdValue | undefined
+}
+
+export interface BreadcrumbItem {
+ name: string
+ path: string
+}
+
+export function createOrganizationJsonLd(): JsonLdObject {
+ const sameAs = [
+ siteSettings.social.twitter,
+ siteSettings.social.youtube,
+ siteSettings.social.github,
+ ].filter((url): url is string => Boolean(url))
+
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'Organization',
+ '@id': `${siteConfig.url}/#organization`,
+ name: siteConfig.name,
+ url: siteConfig.url,
+ logo: absoluteUrl('/apple-touch-icon.png'),
+ description: siteConfig.description,
+ sameAs,
+ }
+}
+
+export function createBreadcrumbListJsonLd(
+ items: ReadonlyArray,
+ locale: string
+): JsonLdObject {
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: items.map((item, index) => ({
+ '@type': 'ListItem',
+ position: index + 1,
+ name: item.name,
+ item: absoluteUrl(item.path, locale),
+ })),
+ }
+}
diff --git a/apps/web/scripts/static-export-smoke.ts b/apps/web/scripts/static-export-smoke.ts
index 21fba140d9..f1aa73d0e1 100644
--- a/apps/web/scripts/static-export-smoke.ts
+++ b/apps/web/scripts/static-export-smoke.ts
@@ -121,6 +121,56 @@ const isLocalAssetHref = (href: string): boolean => {
)
}
+const isRecord = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null && !Array.isArray(value)
+
+const breadcrumbTechRoutes = new Set([
+ ROUTES.blockchain,
+ ROUTES.messaging,
+ ROUTES.networking,
+ ROUTES.storage,
+])
+
+const assertStructuredData = (route: string, html: string): string[] => {
+ const failures: string[] = []
+ const types = new Set()
+ const scripts = html.matchAll(
+ /