docs: generate blog cover images from post titles (#10032)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Bereket Engida
2026-06-15 09:28:45 -07:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent f8f44e3d49
commit 2c8f925c48
10 changed files with 224 additions and 35 deletions
+152
View File
@@ -0,0 +1,152 @@
import { ImageResponse } from "@vercel/og";
import * as z from "zod";
export const runtime = "edge";
const ogSchema = z.object({
title: z.string().trim().min(1).max(200),
date: z.string().trim().max(40).optional(),
theme: z.enum(["light", "dark"]).default("dark"),
});
const themes = {
dark: {
background: "radial-gradient(120% 120% at 80% 0%, #111111 0%, #000000 55%)",
titleGradient:
"linear-gradient(100deg, #fafafa 0%, #d4d4d4 55%, #8a8a8a 100%)",
date: "#8f8f8f",
outline: "rgba(255, 255, 255, 0.09)",
speck: "rgba(255, 255, 255, 0.35)",
},
light: {
background: "radial-gradient(120% 120% at 80% 0%, #ffffff 0%, #f1f1f0 55%)",
titleGradient:
"linear-gradient(100deg, #0a0a0a 0%, #2e2e2e 55%, #6f6f6f 100%)",
date: "#7a7a7a",
outline: "rgba(0, 0, 0, 0.09)",
speck: "rgba(0, 0, 0, 0.3)",
},
} as const;
const specks: Array<{ top: string; left: string; size: number }> = [
{ top: "6%", left: "43%", size: 3 },
{ top: "13%", left: "61%", size: 2 },
{ top: "4%", left: "76%", size: 2 },
{ top: "27%", left: "52%", size: 2 },
{ top: "9%", left: "30%", size: 2 },
{ top: "31%", left: "88%", size: 3 },
];
function titleFontSize(title: string) {
if (title.length <= 40) return 72;
if (title.length <= 80) return 62;
if (title.length <= 120) return 54;
return 46;
}
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const { title, date, theme } = ogSchema.parse(
Object.fromEntries(url.searchParams),
);
const geist = await fetch(
new URL("../../../assets/Geist.ttf", import.meta.url),
).then((res) => res.arrayBuffer());
const colors = themes[theme];
const trimmedTitle =
title.length > 140 ? `${title.substring(0, 137)}...` : title;
return new ImageResponse(
<div
tw="flex w-full h-full relative"
style={{
background: colors.background,
fontFamily: "Geist",
}}
>
{specks.map((speck, i) => (
<div
key={i}
tw="absolute"
style={{
top: speck.top,
left: speck.left,
width: `${speck.size}px`,
height: `${speck.size}px`,
borderRadius: "9999px",
background: colors.speck,
}}
/>
))}
{/* Rotated rounded-rect outline, bleeding off the top-right corner */}
<div
tw="absolute"
style={{
width: "560px",
height: "640px",
top: "-180px",
right: "-200px",
border: `1.5px solid ${colors.outline}`,
borderRadius: "56px",
transform: "rotate(14deg)",
}}
/>
<div
tw="flex flex-col justify-center w-full h-full"
style={{ padding: "0 240px 40px 96px" }}
>
<div
tw="flex"
style={{
fontSize: `${titleFontSize(trimmedTitle)}px`,
fontWeight: 500,
letterSpacing: "-0.02em",
lineHeight: 1.16,
backgroundImage: colors.titleGradient,
backgroundClip: "text",
color: "transparent",
}}
>
{trimmedTitle}
</div>
{date ? (
<div
tw="flex"
style={{
marginTop: "36px",
fontSize: "30px",
color: colors.date,
}}
>
{date}
</div>
) : null}
</div>
</div>,
{
width: 1200,
height: 630,
fonts: [
{
name: "Geist",
data: geist,
weight: 400,
style: "normal",
},
],
headers: {
"Cache-Control":
"public, max-age=86400, s-maxage=31536000, immutable",
},
},
);
} catch (err) {
console.log({ err });
return new Response("Failed to generate the OG image", { status: 500 });
}
}
+72 -27
View File
@@ -20,6 +20,69 @@ function formatDate(date: Date) {
});
}
function generatedCoverUrl(
title: string,
date: Date | undefined,
theme: "light" | "dark",
) {
const params = new URLSearchParams();
params.set("title", title);
if (date) {
params.set(
"date",
new Date(date).toLocaleDateString("en-US", {
month: "long",
year: "numeric",
}),
);
}
params.set("theme", theme);
return `/api/og-blog?${params.toString()}`;
}
function BlogCover({
title,
date,
image,
}: {
title: string;
date: Date;
image?: string;
}) {
if (image) {
return (
<Image
src={image}
alt={title}
width={320}
height={168}
className="w-full h-full object-cover"
/>
);
}
return (
<>
{/* Generated cover, one per theme */}
<img
src={generatedCoverUrl(title, date, "light")}
alt={title}
width={320}
height={168}
className="w-full h-full object-cover dark:hidden"
loading="lazy"
/>
<img
src={generatedCoverUrl(title, date, "dark")}
alt={title}
width={320}
height={168}
className="w-full h-full object-cover hidden dark:block"
loading="lazy"
/>
</>
);
}
function BlogList() {
const posts = blogs
.getPages()
@@ -49,17 +112,13 @@ function BlogList() {
className="group block border-b border-dashed border-foreground/[0.06] px-5 sm:px-6 lg:px-8 py-5 transition-colors hover:bg-foreground/[0.02]"
>
<div className="flex gap-5 items-center">
{post.data?.image && (
<div className="shrink-0 w-56 aspect-[1200/630] overflow-hidden border border-foreground/[0.06] hidden sm:block">
<Image
src={post.data.image}
alt={post.data.title}
width={320}
height={192}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="shrink-0 w-56 aspect-[1200/630] overflow-hidden border border-foreground/[0.06] hidden sm:block">
<BlogCover
title={post.data.title}
date={post.data.date}
image={post.data.image}
/>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-medium tracking-tight text-neutral-800 dark:text-neutral-200 group-hover:text-neutral-950 dark:group-hover:text-white transition-colors">
{post.data.title}
@@ -253,22 +312,8 @@ export async function generateMetadata({
if (!page || page.data.draft) return notFound();
const { title, description, image, date } = page.data;
const ogSearchParams = new URLSearchParams();
ogSearchParams.set("heading", title);
if (description) ogSearchParams.set("description", description);
if (date) {
ogSearchParams.set(
"date",
new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}),
);
}
const ogUrl = `/api/og-release?${ogSearchParams.toString()}`;
const ogImage = image || ogUrl;
// Social cards have no theme preference; always use the dark variant.
const ogImage = image || generatedCoverUrl(title, date, "dark");
return createMetadata({
title,
@@ -5,7 +5,6 @@ date: 2025-08-25
author:
name: "Dagmawi Esayas"
avatar: ""
image: "https://docs.better-auth.com/blogs/supabase-ps.png"
tags: ["migration", "guides", "supabase", "planetscale"]
---
-1
View File
@@ -6,7 +6,6 @@ author:
name: "Bereket Engida"
avatar: "/avatars/beka.jpg"
twitter: "bekacru"
image: "https://docs.better-auth.com/release-og/1-3.png"
tags: ["1.3", "authentication", "oidc", "mcp", "sso", "organization"]
---
-1
View File
@@ -6,7 +6,6 @@ author:
name: "Bereket Engida"
avatar: "/avatars/beka.jpg"
twitter: "bekacru"
image: "https://docs.better-auth.com/release-og/1-4.png"
tags: ["1.4", "stateless sessions", "SCIM", "database indexing", "database join", "SSO domain verification", "uuid", "device authorization"]
---
-1
View File
@@ -6,7 +6,6 @@ author:
name: "Alex Yang"
avatar: "/avatars/alex.png"
twitter: "himseIf_65"
image: "https://docs.better-auth.com/release-og/v1.5-release.png"
tags: ["1.5", "CLI", "MCP", "oauth-provider", "electron", "i18n", "adapters", "cloudflare", "d1", "stripe", "SSO", "SCIM", "test-utils"]
---
-1
View File
@@ -5,7 +5,6 @@ date: 2026-04-07
author:
name: "Taesu Kim"
avatar: ""
image: "/release-og/1-6.png"
tags: ["1.6", "OpenTelemetry", "passkey", "performance", "release workflow"]
---
@@ -6,7 +6,6 @@ author:
name: "Bereket Engida"
avatar: "/avatars/beka.jpg"
twitter: "bekacru"
image: "https://docs.better-auth.com/blogs/authjs-joins.png"
tags: ["seed round", "authentication", "funding"]
---
@@ -5,7 +5,6 @@ date: 2026-06-02
author:
name: "Better Auth Maintainers"
avatar: ""
image: "/blogs/security-june-2026.png"
tags: ["security", "advisories", "release"]
---
-1
View File
@@ -6,7 +6,6 @@ author:
name: "Bereket Engida"
avatar: "/avatars/beka.jpg"
twitter: "bekacru"
image: "https://docs.better-auth.com/blogs/seed-round.png"
tags: ["seed round", "authentication", "funding"]
---