Files
supabase/apps/www/pages/customers/[slug].tsx
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

247 lines
8.4 KiB
TypeScript

import CTABanner from '~/components/CTABanner'
import DefaultLayout from '~/components/Layouts/Default'
import { breadcrumbs } from '~/lib/breadcrumbs'
import { SITE_ORIGIN } from '~/lib/constants'
import { breadcrumbListSchema, serializeJsonLd } from '~/lib/json-ld'
import mdxComponents from '~/lib/mdx/mdxComponents'
import { mdxSerialize } from '~/lib/mdx/mdxSerialize'
import { getAllPostSlugs, getPostdata, getSortedPosts } from '~/lib/posts'
import matter from 'gray-matter'
import { ChevronLeft, ChevronRight, ExternalLink } from 'lucide-react'
import { MDXRemote } from 'next-mdx-remote'
import { NextSeo } from 'next-seo'
import Head from 'next/head'
import Image from 'next/image'
import Link from 'next/link'
import { Button } from 'ui'
// table of contents extractor
const toc = require('markdown-toc')
export async function getStaticPaths() {
const paths = getAllPostSlugs('_customers')
return {
paths,
fallback: false,
}
}
export async function getStaticProps({ params }: any) {
const filePath = `${params.slug}`
const postContent = await getPostdata(filePath, '_customers')
const { data, content } = matter(postContent)
const mdxSource: any = await mdxSerialize(content)
const relatedPosts = getSortedPosts({
directory: '_customers',
limit: 5,
tags: mdxSource.scope.tags,
currentPostSlug: filePath,
})
const allPosts = getSortedPosts({ directory: '_customers' })
const currentIndex = allPosts
.map(function (e) {
return e.slug
})
.indexOf(filePath)
const nextPost = allPosts[currentIndex + 1]
const prevPost = allPosts[currentIndex - 1]
const payload = {
props: {
prevPost: currentIndex === 0 ? null : prevPost ? prevPost : null,
nextPost: currentIndex === allPosts.length ? null : nextPost ? nextPost : null,
relatedPosts,
blog: {
slug: `${params.slug}`,
content: mdxSource,
source: content,
...data,
toc: toc(content, { maxdepth: data.toc_depth ? data.toc_depth : 2 }),
},
},
}
return payload
}
function CaseStudyPage(props: any) {
const {
about,
company_url,
content,
date,
description,
logo,
meta_description,
meta_title,
misc,
name,
slug,
title,
} = props.blog
const ogImageUrl = encodeURI(
`${process.env.NODE_ENV === 'development' ? 'http://127.0.0.1:54321' : 'https://obuldanrptloktxcffvn.supabase.co'}/functions/v1/og-images?site=customers&customer=${slug}&title=${meta_title ?? title}`
)
const meta = {
title: meta_title ?? `${name} | Supabase Customer Stories`,
description: meta_description ?? description,
image: ogImageUrl ?? `${SITE_ORIGIN}/images/customers/og/customer-stories.jpg`,
url: `${SITE_ORIGIN}/customers/${slug}`,
}
const breadcrumbItems = [
...breadcrumbs.customersIndex,
{ name: meta_title ?? title, url: `https://supabase.com/customers/${slug}` },
]
return (
<>
<NextSeo
title={meta.title}
openGraph={{
title: meta.title,
description: meta.description,
url: meta.url,
type: 'article',
article: {
//
// to do: add expiration and modified dates
// https://github.com/garmeeh/next-seo#article
publishedTime: date,
},
images: [
{
url: meta.image,
alt: `${meta.title} thumbnail`,
},
],
}}
/>
<Head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: serializeJsonLd(breadcrumbListSchema(breadcrumbItems)),
}}
/>
</Head>
<DefaultLayout>
<div
className="
container mx-auto p-8 sm:py-16 sm:px-16
xl:px-20
"
>
<div className="grid grid-cols-12 gap-4">
<div className="hidden xl:block col-span-12 mb-2 xl:col-span-2">
{/* Back button */}
<Link
href="/customers"
className="text-foreground-lighter hover:text-foreground flex cursor-pointer items-center text-sm transition"
>
<ChevronLeft style={{ padding: 0 }} />
Back
</Link>
</div>
<div className="col-span-12 lg:col-span-8">
<div>
<article className="flex flex-col gap-8">
<div className="flex flex-col gap-4 sm:gap-8 max-w-xxl">
<Link
href="/customers"
className="text-brand hover:text-brand-600 sm:mb-2 mt-0"
>
Customer Stories
</Link>
<h1 className="text-foreground text-4xl font-semibold xl:text-5xl">{title}</h1>
<p className="text-foreground text-xl xl:text-2xl">{description}</p>
</div>
<div className="grid grid-cols-12 prose max-w-none gap-8 lg:gap-20">
<div className="col-span-12 lg:col-span-4 lg:block xl:col-span-4">
<div className="space-y-8 lg:sticky lg:top-24 lg:mb-24">
{/* Logo */}
<div className="relative h-16 w-32 lg:mt-5">
<Image
fill
src={logo}
alt={`${title} logo`}
priority
placeholder="blur"
blurDataURL="/images/blur.png"
draggable={false}
className="
bg-no-repeat
object-left
object-contain
m-0
in-data-[theme*=dark]:brightness-200
in-data-[theme*=dark]:contrast-0
in-data-[theme*=dark]:filter
"
/>
</div>
<div className="flex flex-col space-y-2">
<span className="text-foreground-lighter">About</span>
<p>{about}</p>
{company_url && (
<span className="not-prose ">
<a
href={company_url}
className="flex cursor-pointer items-center space-x-1 transition-opacity text-foreground-lightround-ligtext-foreground-light:text-foreground-light"
target="_blank"
>
<span>{company_url}</span>
<ExternalLink size={14} />
</a>
</span>
)}
</div>
{misc?.map((x: any) => {
return (
<div className="flex flex-col gap-0">
<span className="text-foreground-lighter">{x.label}</span>
<span className="text-foreground-light">{x.text}</span>
</div>
)
})}
<div>
<p>Ready to get started?</p>
<div>
<Button asChild type="default" iconRight={<ChevronRight />}>
<Link
href="https://supabase.com/contact/enterprise"
className="no-underline"
>
Contact sales
</Link>
</Button>
</div>
</div>
</div>
</div>
<div className="xm:col-span-7 col-span-12 lg:col-span-8 xl:col-span-8 ">
<MDXRemote {...content} components={mdxComponents()} />
</div>
</div>
</article>
</div>
</div>
</div>
</div>
<CTABanner />
</DefaultLayout>
</>
)
}
export default CaseStudyPage