Files
supabase/apps/www/lib/json-ld.ts
Pamela Chia c26b64a033 feat(www): emit BreadcrumbList JSON-LD on marketing surfaces (#45478)
## Summary

- Adds `breadcrumbListSchema(items)` helper to `apps/www/lib/json-ld.ts`
and a hand-curated `apps/www/lib/breadcrumbs.ts` route map.
- Wires inline `<script type="application/ld+json">` BreadcrumbList
blocks into 18 marketing surfaces: blog (index + slug), customers (index
+ slug), events (index + slug), 5 product pages (database, auth,
storage, edge-functions, realtime), 3 modules (vector, cron, queues),
pricing, careers, company, features.
- Pages router callers wrap the script in `<Head>`; app router callers
place it directly in JSX. Dynamic surfaces append a leaf at render time
using the page's title (`frontmatter.title` for blog, `meta_title ??
title` for customers, `event.meta_title ?? event.title` for events).
- Modules sit at `Home > {Name}` since no `/modules` index page exists;
products sit at `Home > {Product}` (no shared products parent). Absolute
`https://supabase.com` URLs match the existing `CANONICAL_ORIGIN`
convention so anchors stay stable across Vercel previews.

Linear:
[GROWTH-822](https://linear.app/supabase/issue/GROWTH-822/add-breadcrumblist-json-ld-to-www-marketing-surfaces)
(sub-issue under
[GROWTH-724](https://linear.app/supabase/issue/GROWTH-724)).

> **Note on branch name:** the branch is
`pamela/growth-820-www-breadcrumb-jsonld`; the actual Linear issue is
GROWTH-822. The branch was named before the sub-issue was created.
Ignore the `820` in the branch.

Explicitly deferred (separate PRs / low SEO ROI): `/launch-week/*`,
`/solutions/*`, `/partners/*`, `/alternatives/*`, `/changelog`,
`/legal/dpa`, `/aws-reinvent-2025`, `/wrapped`, `/contribute/*`,
`/brand-assets`, `/ga`, `/ga-week`, `/state-of-startups*`, and the
homepage (Organization + WebSite already cover homepage entity signals;
single-item BreadcrumbList is ignored by Google).

## Test plan

- [x] On the Vercel preview, `curl -s https://<preview>/database | grep
'"BreadcrumbList"'` returns the script block with `Home > Database`.
- [x] `curl -s https://<preview>/blog/<recent-slug> | grep
'"BreadcrumbList"'` returns `Home > Blog > {post title}`.
- [x] `curl -s https://<preview>/customers/<slug> | grep
'"BreadcrumbList"'` returns `Home > Customer Stories > {customer
title}`.
- [x] `curl -s https://<preview>/events/<slug> | grep
'"BreadcrumbList"'` returns `Home > Events > {event title}`.
- [x] `curl -s https://<preview>/modules/vector | grep
'"BreadcrumbList"'` returns `Home > Vector`.
2026-05-05 23:57:53 +08:00

141 lines
3.4 KiB
TypeScript

import { DEFAULT_META_DESCRIPTION, SITE_NAME } from './constants'
const CANONICAL_ORIGIN = 'https://supabase.com'
const ORG_ID = `${CANONICAL_ORIGIN}/#organization`
const SITE_ID = `${CANONICAL_ORIGIN}/#website`
const ORG_SAMEAS = [
'https://github.com/supabase',
'https://twitter.com/supabase',
'https://www.linkedin.com/company/supabase',
'https://youtube.com/c/supabase',
'https://discord.supabase.com/',
'https://www.tiktok.com/@supabase.com',
'https://www.instagram.com/supabasecom',
]
const ORG_LOGO_URL = `${CANONICAL_ORIGIN}/images/og/supabase-og.png`
type JsonLdSchema = Record<string, unknown> | Record<string, unknown>[]
export function serializeJsonLd(schema: JsonLdSchema): string {
return JSON.stringify(schema)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
}
interface OrganizationSchemaInput {
description?: string
}
export function organizationSchema(input: OrganizationSchemaInput = {}) {
return {
'@context': 'https://schema.org',
'@type': 'Organization',
'@id': ORG_ID,
name: SITE_NAME,
url: CANONICAL_ORIGIN,
logo: {
'@type': 'ImageObject',
url: ORG_LOGO_URL,
},
description: input.description ?? DEFAULT_META_DESCRIPTION,
sameAs: ORG_SAMEAS,
}
}
interface WebSiteSchemaInput {
description?: string
}
export function websiteSchema(input: WebSiteSchemaInput = {}) {
return {
'@context': 'https://schema.org',
'@type': 'WebSite',
'@id': SITE_ID,
url: CANONICAL_ORIGIN,
name: SITE_NAME,
description: input.description ?? DEFAULT_META_DESCRIPTION,
publisher: { '@id': ORG_ID },
}
}
interface SoftwareApplicationSchemaInput {
name: string
description: string
url: string
image: string
applicationCategory?: string
}
export function softwareApplicationSchema(input: SoftwareApplicationSchemaInput) {
return {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: input.name,
description: input.description,
url: input.url,
image: input.image,
applicationCategory: input.applicationCategory ?? 'DeveloperApplication',
operatingSystem: 'Cross-platform',
publisher: { '@id': ORG_ID },
}
}
export interface BreadcrumbItem {
name: string
url: string
}
export function breadcrumbListSchema(items: BreadcrumbItem[]) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: item.url,
})),
}
}
interface BlogPostingSchemaInput {
url: string
headline: string
description?: string
image: string
datePublished: string
authors: Array<{ name: string; url?: string }>
}
export function blogPostingSchema(input: BlogPostingSchemaInput) {
return {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
mainEntityOfPage: {
'@type': 'WebPage',
'@id': input.url,
},
headline: input.headline,
description: input.description,
image: input.image,
datePublished: input.datePublished,
author: input.authors.map((a) => ({
'@type': 'Person',
name: a.name,
...(a.url ? { url: a.url } : {}),
})),
publisher: {
'@type': 'Organization',
'@id': ORG_ID,
name: SITE_NAME,
logo: {
'@type': 'ImageObject',
url: ORG_LOGO_URL,
},
},
}
}