Skip to content

Commit 50a39ac

Browse files
authored
feat: generate per-page Open Graph social cards (#617)
Add an on-demand OG image route (app/api/og) that renders a 1200x630 branded card with the page title and section, plus a lib/og.ts helper to build its URL. Wire it into docs, blog, and changelog metadata so every shared link gets a unique preview instead of one of four static images. Author-provided blog/changelog ogImage values still take precedence.
1 parent 3fe8cd1 commit 50a39ac

5 files changed

Lines changed: 128 additions & 6 deletions

File tree

‎app/api/og/route.tsx‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { ImageResponse } from 'next/og'
2+
3+
/**
4+
* On-demand Open Graph image generator. Renders a 1200x630 branded social card
5+
* with the page title and section, so every shared link gets a unique preview.
6+
* Called via lib/og.ts `ogImageUrl()` from page metadata; CDN-cached by URL.
7+
*/
8+
export const runtime = 'nodejs'
9+
10+
const TYPE_LABELS: Record<string, string> = {
11+
docs: 'Documentation',
12+
blog: 'Blog',
13+
changelog: 'Changelog',
14+
}
15+
16+
export function GET(request: Request) {
17+
const { searchParams } = new URL(request.url)
18+
const rawTitle = searchParams.get('title')?.trim()
19+
const title = (rawTitle && rawTitle.length > 0 ? rawTitle : 'The Open-Source AI Platform').slice(
20+
0,
21+
110,
22+
)
23+
const type = searchParams.get('type') ?? ''
24+
const label = TYPE_LABELS[type] ?? 'librechat.ai'
25+
// Scale the title down for longer headlines so they stay on the card.
26+
const fontSize = title.length > 70 ? 56 : title.length > 40 ? 68 : 80
27+
28+
return new ImageResponse(
29+
<div
30+
style={{
31+
width: '100%',
32+
height: '100%',
33+
display: 'flex',
34+
flexDirection: 'column',
35+
justifyContent: 'space-between',
36+
padding: '80px',
37+
backgroundColor: '#0a0a0a',
38+
backgroundImage:
39+
'radial-gradient(circle at 18% 0%, rgba(99,102,241,0.22), transparent 45%), radial-gradient(circle at 100% 100%, rgba(56,189,248,0.16), transparent 45%)',
40+
color: '#fafafa',
41+
fontFamily: 'sans-serif',
42+
}}
43+
>
44+
{/* Brand */}
45+
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
46+
<div
47+
style={{
48+
display: 'flex',
49+
alignItems: 'center',
50+
justifyContent: 'center',
51+
width: '64px',
52+
height: '64px',
53+
borderRadius: '16px',
54+
background: 'linear-gradient(135deg, #6366f1, #38bdf8)',
55+
fontSize: '40px',
56+
}}
57+
>
58+
🪶
59+
</div>
60+
<div style={{ fontSize: '34px', fontWeight: 700, letterSpacing: '-0.02em' }}>LibreChat</div>
61+
</div>
62+
63+
{/* Title */}
64+
<div
65+
style={{
66+
display: 'flex',
67+
fontSize,
68+
fontWeight: 700,
69+
lineHeight: 1.1,
70+
letterSpacing: '-0.03em',
71+
maxWidth: '1000px',
72+
}}
73+
>
74+
{title}
75+
</div>
76+
77+
{/* Footer */}
78+
<div
79+
style={{
80+
display: 'flex',
81+
alignItems: 'center',
82+
justifyContent: 'space-between',
83+
fontSize: '26px',
84+
color: '#a1a1aa',
85+
}}
86+
>
87+
<div
88+
style={{
89+
display: 'flex',
90+
textTransform: 'uppercase',
91+
letterSpacing: '0.12em',
92+
fontWeight: 600,
93+
}}
94+
>
95+
{label}
96+
</div>
97+
<div style={{ display: 'flex' }}>www.librechat.ai</div>
98+
</div>
99+
</div>,
100+
{ width: 1200, height: 630 },
101+
)
102+
}

‎app/blog/[slug]/page.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { mdxComponents } from '@/lib/mdx-components'
66
import { blog } from '@/lib/source'
77
import { JsonLd } from '@/components/JsonLd'
88
import { articleSchema, breadcrumbSchema } from '@/lib/structured-data'
9+
import { ogImageUrl } from '@/lib/og'
910
import type { Metadata } from 'next'
1011

1112
interface PageProps {
@@ -38,7 +39,8 @@ export default async function BlogPostPage(props: PageProps) {
3839
headline: post.title,
3940
description: post.description,
4041
url: `/blog/${params.slug}`,
41-
image: post.ogMetaImage ?? post.ogImage ?? '/images/socialcards/default-blog-image.png',
42+
image:
43+
post.ogMetaImage ?? post.ogImage ?? ogImageUrl({ title: post.title, type: 'blog' }),
4244
datePublished: new Date(date).toISOString(),
4345
authorName: (post as any).author,
4446
}),
@@ -107,7 +109,8 @@ export async function generateMetadata(props: PageProps): Promise<Metadata> {
107109
const post = findPost(params.slug)
108110
if (!post) notFound()
109111

110-
const ogImage = post.ogMetaImage ?? post.ogImage ?? '/images/socialcards/default-blog-image.png'
112+
const ogImage =
113+
post.ogMetaImage ?? post.ogImage ?? ogImageUrl({ title: post.title, type: 'blog' })
111114
const publishedTime =
112115
typeof post.date === 'string' ? post.date : new Date(post.date).toISOString()
113116

‎app/changelog/[slug]/page.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { mdxComponents } from '@/lib/mdx-components'
44
import { changelog } from '@/lib/source'
55
import { JsonLd } from '@/components/JsonLd'
66
import { articleSchema, breadcrumbSchema } from '@/lib/structured-data'
7+
import { ogImageUrl } from '@/lib/og'
78
import type { Metadata } from 'next'
89

910
interface PageProps {
@@ -36,7 +37,7 @@ export default async function ChangelogDetailPage(props: PageProps) {
3637
headline: entry.title,
3738
description: entry.description,
3839
url: `/changelog/${params.slug}`,
39-
image: (entry as any).ogImage ?? '/images/socialcards/default-changelog-image.png',
40+
image: (entry as any).ogImage ?? ogImageUrl({ title: entry.title, type: 'changelog' }),
4041
datePublished: new Date(date).toISOString(),
4142
}),
4243
breadcrumbSchema([
@@ -92,7 +93,7 @@ export async function generateMetadata(props: PageProps): Promise<Metadata> {
9293
const entry = findEntry(params.slug)
9394
if (!entry) notFound()
9495

95-
const ogImage = (entry as any).ogImage ?? '/images/socialcards/default-changelog-image.png'
96+
const ogImage = (entry as any).ogImage ?? ogImageUrl({ title: entry.title, type: 'changelog' })
9697

9798
return {
9899
title: entry.title,

‎app/docs/[[...slug]]/page.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { LLMCopyButton, ViewOptions } from '@/components/page-actions'
66
import { Feedback } from '@/components/Feedback'
77
import { JsonLd } from '@/components/JsonLd'
88
import { articleSchema, breadcrumbSchema } from '@/lib/structured-data'
9+
import { ogImageUrl } from '@/lib/og'
910
import type { Metadata } from 'next'
1011

1112
interface PageProps {
@@ -43,7 +44,7 @@ export default async function Page(props: PageProps) {
4344
headline: page.data.title,
4445
description: page.data.description,
4546
url: page.url,
46-
image: '/images/socialcards/default-docs-image.png',
47+
image: ogImageUrl({ title: page.data.title, type: 'docs' }),
4748
dateModified: lastModified,
4849
}),
4950
breadcrumbSchema([
@@ -87,7 +88,7 @@ export async function generateMetadata(props: PageProps): Promise<Metadata> {
8788
description: page.data.description,
8889
type: 'article',
8990
url: page.url,
90-
images: ['/images/socialcards/default-docs-image.png'],
91+
images: [ogImageUrl({ title: page.data.title, type: 'docs' })],
9192
},
9293
}
9394
}

‎lib/og.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Builds the URL for an on-demand Open Graph image (see app/api/og/route.tsx).
3+
* Used in generateMetadata so every page gets a social card rendered with its
4+
* own title instead of one shared static image. Relative URLs resolve against
5+
* `metadataBase` in the root layout.
6+
*/
7+
export type OgType = 'docs' | 'blog' | 'changelog'
8+
9+
export function ogImageUrl(opts: { title?: string; type?: OgType } = {}): string {
10+
const params = new URLSearchParams()
11+
if (opts.title) params.set('title', opts.title)
12+
if (opts.type) params.set('type', opts.type)
13+
const qs = params.toString()
14+
return `/api/og${qs ? `?${qs}` : ''}`
15+
}

0 commit comments

Comments
 (0)