Add OG image for social media previews (#1861)

* Add dynamic OG image and use large Twitter cards

Generate a 1200x630 OG image with the cmux logo, tagline, and
description using next/og ImageResponse. Switch Twitter card type
from "summary" to "summary_large_image" across all pages so shared
links show a full-width preview instead of the tiny favicon thumbnail.

* Use Geist font and app screenshot in OG image, update landing/README images

Replace the centered text-only OG image with a split layout: branding
on the left (logo, name, tagline) and a full app screenshot on the
right. Load Geist Regular/SemiBold from Google Fonts for consistent
typography. Replace the homepage landing image and README screenshot
with a new screenshot showing cmux with multiple workspaces, tabs,
browser panel, and code diffs.

* Fine-tune OG image layout and update homepage/README screenshots

Apply tuned values from OG editor: 112px logo, 48px title with -8
translateY, 34px subtitle at #cfcfcf, 320px fade height. Use Geist
font loaded from Google Fonts. Render at 2x (2400x1260) for sharper
previews on social platforms. Remove GitHub URL from footer.

Add pre-resized og-screenshot.png (2208px wide) for the OG image to
avoid Satori downscale blur. Update homepage landing image and README
screenshot with new app screenshot.

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
Lawrence Chen 2026-03-20 19:01:29 -07:00 committed by GitHub
parent 4376e6e19a
commit 39c03c9b07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 139 additions and 5 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 3.4 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 3.4 MiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View file

@ -21,7 +21,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
url,
},
twitter: {
card: "summary",
card: "summary_large_image",
title: t("metaTitle"),
description: t("metaDescription"),
},

View file

@ -21,7 +21,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
url,
},
twitter: {
card: "summary",
card: "summary_large_image",
title: t("metaTitle"),
description: t("metaDescription"),
},

View file

@ -25,7 +25,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
url,
},
twitter: {
card: "summary",
card: "summary_large_image",
title: t("metaTitle"),
description: t("metaDescription"),
},

View file

@ -21,7 +21,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
url,
},
twitter: {
card: "summary",
card: "summary_large_image",
title: t("metaTitle"),
description: t("metaDescription"),
},

View file

@ -57,7 +57,7 @@ export async function generateMetadata({
type: "website",
},
twitter: {
card: "summary",
card: "summary_large_image",
title: t("title"),
description: t("ogDescription"),
},

View file

@ -0,0 +1,134 @@
import { ImageResponse } from "next/og";
import { readFile } from "fs/promises";
import { join } from "path";
export const runtime = "nodejs";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export const alt = "cmux — The terminal built for multitasking";
const S = 2; // render at 2x for sharper images on social platforms
export default async function Image() {
const [logoData, screenshotData, geistRegular, geistSemiBold] =
await Promise.all([
readFile(join(process.cwd(), "public", "logo.png")),
readFile(
join(process.cwd(), "app", "[locale]", "assets", "og-screenshot.png")
),
fetch(
"https://fonts.gstatic.com/s/geist/v4/gyBhhwUxId8gMGYQMKR3pzfaWI_RnOM4nQ.ttf"
).then((res) => res.arrayBuffer()),
fetch(
"https://fonts.gstatic.com/s/geist/v4/gyBhhwUxId8gMGYQMKR3pzfaWI_RQuQ4nQ.ttf"
).then((res) => res.arrayBuffer()),
]);
const logoSrc = `data:image/png;base64,${logoData.toString("base64")}`;
const screenshotSrc = `data:image/png;base64,${screenshotData.toString("base64")}`;
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#0a0a0a",
fontFamily: "Geist",
paddingBottom: 28 * S,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
flex: 1,
}}
>
{/* Screenshot */}
<div
style={{
display: "flex",
flex: 1,
overflow: "hidden",
position: "relative",
}}
>
<img src={screenshotSrc} width={size.width * S} />
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: 320 * S,
background:
"linear-gradient(to bottom, rgba(10,10,10,0), rgba(10,10,10,1))",
}}
/>
</div>
{/* Branding bar */}
<div
style={{
display: "flex",
alignItems: "center",
marginTop: -60 * S,
paddingLeft: 25 * S,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 20 * S,
}}
>
<img
src={logoSrc}
width={112 * S}
height={112 * S}
style={{ borderRadius: 20 * S }}
/>
<div style={{ display: "flex", flexDirection: "column" }}>
<div
style={{
fontSize: 48 * S,
fontWeight: 600,
color: "#ededed",
letterSpacing: "-0.02em",
lineHeight: 1,
marginTop: -8 * S,
}}
>
cmux
</div>
<div
style={{
fontSize: 34 * S,
fontWeight: 400,
color: "#cfcfcf",
marginTop: 5 * S,
lineHeight: 1,
}}
>
The terminal built for multitasking
</div>
</div>
</div>
</div>
</div>
</div>
),
{
width: size.width * S,
height: size.height * S,
fonts: [
{ name: "Geist", data: geistRegular, weight: 400, style: "normal" },
{ name: "Geist", data: geistSemiBold, weight: 600, style: "normal" },
],
}
);
}