diff --git a/packages/storybook/.storybook/preview.tsx b/packages/storybook/.storybook/preview.tsx
index 38d5deaeb84..267f120534f 100644
--- a/packages/storybook/.storybook/preview.tsx
+++ b/packages/storybook/.storybook/preview.tsx
@@ -18,6 +18,7 @@ const preview: Preview = {
'Atoms',
'Components',
'Pages',
+ 'Open Graph',
'Experiments',
'Extension',
],
diff --git a/packages/storybook/stories/open-graph/Benchmark.stories.tsx b/packages/storybook/stories/open-graph/Benchmark.stories.tsx
new file mode 100644
index 00000000000..6c8945c2802
--- /dev/null
+++ b/packages/storybook/stories/open-graph/Benchmark.stories.tsx
@@ -0,0 +1,526 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import {
+ Bullets,
+ Divider,
+ Heading,
+ Muted,
+ Page,
+ PageHeader,
+ SpecTable,
+} from './ogStoryLayout';
+
+const SANS =
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
+
+const CardLabel = ({
+ children,
+}: {
+ children: React.ReactNode;
+}): React.ReactElement => (
+
+ {children}
+
+);
+
+/** How a YouTube link unfurls — the most-shared link type on the web. */
+const YouTubeCard = (): React.ReactElement => (
+
+
+
+
+ ▶
+
+
+
+ 12:04
+
+
+
+
+
+
+ How we cut edge cold-starts by 90% with Rust and Wasm
+
+
+ The Pragmatic Engineer · 1.2M views
+
+
+
+
+);
+
+/** How a Reddit link post unfurls — the model that sits between GitHub and X. */
+const RedditCard = (): React.ReactElement => (
+
+
+ ▲
+
+ 2.4k
+
+
+ ▼
+
+
+
+
+ r/programming · Posted by u/idoshamun · 5h
+
+
+ How we cut edge cold-starts by 90% with Rust and Wasm
+
+
+ app.daily.dev
+
+
+ 💬 340 comments
+ ↗ Share
+
+
+
+
+);
+
+/** GitHub's auto-generated repo social card. */
+const GitHubRepoCard = (): React.ReactElement => (
+
+
+
+
+ vercel /{' '}
+ satori
+
+
+
+ Enlightened library to convert HTML and CSS to SVG
+
+
+
+
+ TypeScript
+
+ ★ 11.8k
+ ⑂ 312
+
+
+);
+
+const Benchmark = (): React.ReactElement => (
+
+
+ The question that matters isn’t how much traffic a platform sends us —
+ it’s whose links get shared everywhere else: pasted into
+ WhatsApp, posted on X, dropped in a Slack channel. So we looked at the
+ platforms whose links travel the most across the web and studied how they
+ unfurl. The pattern is stark: the most-shared platforms have{' '}
+ rich, open, contextual previews — and the ones that gate
+ their metadata get shared less because their links look broken.
+
+
+ Whose links get shared the most
+
+
+
+
+
+ The three to model
+
+ YouTube, Reddit and GitHub are where the most-shared, best-unfurling links
+ live. Here’s how each looks and exactly what we should take.
+
+
+
+
+
YouTube — the thumbnail IS the product
+
+
+
+
+
+
+
GitHub — contextual + live stats
+
+
+
+
+
+
+
+
+
+ Reddit — between GitHub and X
+
+ You called it: for developers Reddit sits right between GitHub’s
+ credibility and X’s reach. It’s a social network and a dev
+ watering hole, and its link cards are a masterclass in context — exactly
+ the balance daily.dev wants.
+
+
+
+ How a daily.dev link looks on Reddit
+
+
+
+
+
+
+
+
+
+ The anti-pattern: don’t be Instagram or TikTok
+
+
+ The takeaway for us:{' '}
+ openness + complete, valid tags is itself a growth lever.{' '}
+ Every daily.dev link should unfurl richly for every scraper (Facebook, X,
+ Slack, Discord, and Reddit’s Embedly) — never gated, never half-formed.
+
+
+
+
+ Enhanced principles (from the most-shared platforms)
+
+
+
+
+ Sources
+
+
+);
+
+const meta: Meta = {
+ title: 'Open Graph/6. How the Best Platforms Share',
+ component: Benchmark,
+ parameters: { layout: 'fullscreen' },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Benchmarks: Story = { name: 'Platform benchmark' };
diff --git a/packages/storybook/stories/open-graph/CurrentOpenGraph.stories.tsx b/packages/storybook/stories/open-graph/CurrentOpenGraph.stories.tsx
new file mode 100644
index 00000000000..44ea412f0b9
--- /dev/null
+++ b/packages/storybook/stories/open-graph/CurrentOpenGraph.stories.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { MetaTagsTable, PlatformGrid } from './platformCards';
+import { USE_CASES } from './useCases';
+import {
+ Bullets,
+ Divider,
+ Heading,
+ Muted,
+ Page,
+ PageHeader,
+} from './ogStoryLayout';
+
+const CurrentPreviews = (): React.ReactElement => (
+
+
+ Every place daily.dev content gets shared, rendered the way it actually
+ unfurls on X, LinkedIn, Facebook, Slack, Discord, WhatsApp and iMessage.
+ These are the real, live images daily.dev serves today —
+ pulled straight from the actual URLs (e.g.{' '}
+ og.daily.dev/api/posts/{id} for posts,{' '}
+ daily.dev/og-image.png for the homepage), not re-creations.
+ The tell is obvious: only posts get a real card — profile, source, tag,
+ squad, invite and Plus all fall back to the same generic image.
+
+
+ {USE_CASES.map((uc, i) => (
+
+
+ {i + 1}. {uc.name}
+
+ {uc.what}
+
+
+
+
+
+
+
+
+
+ {i < USE_CASES.length - 1 && }
+
+ ))}
+
+);
+
+const meta: Meta = {
+ title: 'Open Graph/1. Current Share Previews',
+ component: CurrentPreviews,
+ parameters: { layout: 'fullscreen' },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const AllUseCases: Story = { name: 'All use cases' };
diff --git a/packages/storybook/stories/open-graph/LinkCopyBehavior.stories.tsx b/packages/storybook/stories/open-graph/LinkCopyBehavior.stories.tsx
new file mode 100644
index 00000000000..888b882d1c7
--- /dev/null
+++ b/packages/storybook/stories/open-graph/LinkCopyBehavior.stories.tsx
@@ -0,0 +1,314 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import {
+ Bullets,
+ CodeBlock,
+ Divider,
+ Heading,
+ Muted,
+ Page,
+ PageHeader,
+ SpecTable,
+ TwoCol,
+} from './ogStoryLayout';
+
+const SANS =
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
+
+/** Anatomy of a link preview, labeling the parts that are NOT the image. */
+const PreviewAnatomy = (): React.ReactElement => (
+
+
+
+
+
+ d.
+
+
+ app.daily.dev · favicon + domain
+
+
+
+ og:title — the headline
+
+
+ og:description — the supporting line shown on FB, Slack, Discord,
+ WhatsApp.
+
+
+
+);
+
+const SHARE_TEXT_CODE = `// Today — shared/src/lib/share.ts
+getTwitterShareLink(link, text) // → "{text} via @dailydotdev"
+// "text" is usually just the post title, so a tweet reads:
+// "How we cut edge cold-starts… via @dailydotdev app.daily.dev/posts/…"
+
+// Recommended — context-aware, pre-filled share copy (still user-editable)
+share.article = \`\${title}\\n\\nvia @dailydotdev\`;
+share.invite = \`I use daily.dev to keep up with dev news — join me 👇\`;
+share.profile = \`My developer profile on @dailydotdev 👇\`;
+share.squad = \`We're sharing the best \${topic} content in this Squad 👇\`;
+// Keep "via @dailydotdev" for attribution; add 1 relevant emoji max; no hashtag spam.`;
+
+const LinkCopyBehavior = (): React.ReactElement => (
+
+
+ The image gets the attention, but half of a great link preview is
+ everything around it: the title wording, the description, the little
+ favicon and brand avatar, the URL that shows, and the message we pre-fill
+ when someone taps “share”. This page covers all of it — current state vs.
+ what we should do — for the parts that live outside the Open
+ Graph image.
+
+
+ Anatomy — the non-image parts
+
+
+
+
+ On messengers and Slack/Discord/Facebook the text{' '}
+ does most of the work — the image may be small or absent. The favicon
+ and domain are the trust signal. The pre-filled share message is what
+ actually frames the link for the recipient.
+
+
+
+
+
+
+ Title & description copy
+
+
+
+
+ Favicon, site name & brand avatar
+
+ Slack, Discord, iMessage and Reddit show a small favicon next to the
+ domain; X shows the @dailydotdev profile avatar. These are
+ tiny but they’re the brand’s handshake.
+
+
+
+
+
+ The pre-filled share message
+
+ When a user shares from inside daily.dev, we pre-fill the text. Today it’s
+ basically the title plus “via @dailydotdev”. Tailoring it per context (and
+ keeping it editable) makes the share feel personal and lifts CTR.
+
+ {SHARE_TEXT_CODE}
+
+ }
+ right={
+
+ }
+ />
+
+
+
+ URLs & link behavior
+
+
+
+
+
+ Priorities (beyond the image)
+
+
+);
+
+const meta: Meta = {
+ title: 'Open Graph/7. Link Copy, Metadata & Behavior',
+ component: LinkCopyBehavior,
+ parameters: { layout: 'fullscreen' },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const CopyAndBehavior: Story = { name: 'Copy, metadata & behavior' };
diff --git a/packages/storybook/stories/open-graph/OpenGraphComparison.stories.tsx b/packages/storybook/stories/open-graph/OpenGraphComparison.stories.tsx
new file mode 100644
index 00000000000..74210b94538
--- /dev/null
+++ b/packages/storybook/stories/open-graph/OpenGraphComparison.stories.tsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import {
+ LinkedInCard,
+ MetaTagsTable,
+ WhatsAppCard,
+ XCard,
+} from './platformCards';
+import type { OgData } from './platformCards';
+import { USE_CASES } from './useCases';
+import {
+ Bullets,
+ Divider,
+ Heading,
+ Muted,
+ Page,
+ PageHeader,
+ TwoCol,
+} from './ogStoryLayout';
+
+const Stack = ({ data }: { data: OgData }): React.ReactElement => (
+
+);
+
+const Comparison = (): React.ReactElement => (
+
+
+ A side-by-side of what we ship today (the{' '}
+ real, live images, pulled from the actual URLs) against a
+ single, unified template system that adapts per share type. Each surface
+ keeps the same visual language so a daily.dev link is instantly
+ recognizable, while the headline, attribution and context change to fit
+ the object being shared. Red column = today; green column = proposed.
+
+
+ {USE_CASES.map((uc, i) => (
+
+
+ {i + 1}. {uc.name}
+
+ {uc.what}
+
+
+ }
+ right={}
+ />
+
+
+ }
+ right={}
+ />
+
+ {i < USE_CASES.length - 1 && }
+
+ ))}
+
+);
+
+const meta: Meta = {
+ title: 'Open Graph/2. Current vs Recommended',
+ component: Comparison,
+ parameters: { layout: 'fullscreen' },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const SideBySide: Story = { name: 'Side by side' };
diff --git a/packages/storybook/stories/open-graph/OpenGraphGuidelines.stories.tsx b/packages/storybook/stories/open-graph/OpenGraphGuidelines.stories.tsx
new file mode 100644
index 00000000000..367bcbb2615
--- /dev/null
+++ b/packages/storybook/stories/open-graph/OpenGraphGuidelines.stories.tsx
@@ -0,0 +1,314 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import {
+ Bullets,
+ CodeBlock,
+ Divider,
+ Heading,
+ Muted,
+ Page,
+ PageHeader,
+ SpecTable,
+} from './ogStoryLayout';
+
+const Guidelines = (): React.ReactElement => (
+
+
+ A condensed field guide to how link previews actually work across
+ platforms in 2026, the technical details that matter, and the concrete
+ changes we should make to daily.dev’s sharing. Pair this with the “Current
+ vs Recommended” page for the per-surface mock-ups.
+
+
+ The one image spec that covers everything
+
+ Ship a single 1200 × 630 px (1.91:1) image. It satisfies
+ Facebook, LinkedIn, X large cards, Slack, Discord, WhatsApp and iMessage
+ without cropping the important bits. Keep the file under{' '}
+ ~500 KB (hard ceiling ~1 MB or some crawlers skip it),
+ prefer PNG for text-heavy cards / JPEG for photographic ones, and keep all
+ critical content inside a centered ~80% safe area since
+ some surfaces center-crop toward square.
+
+
+
+
+
+ Practical takeaway: write for the strictest platform.{' '}
+ Keep og:title ≤ ~60 chars and og:description ≤ ~110 chars so nothing
+ important is clipped on LinkedIn/X while still reading well on
+ Slack/Facebook where more is shown.
+
+
+
+
+ Tags we should always emit
+ ',
+ 'Lets Reddit (Embedly), Discord & others build richer cards',
+ '✗ Likely missing — add for the Reddit/dev audience',
+ ],
+ ]}
+ />
+
+ {`
+
+
+
+
+
+
+
+
+
+
+
+
+
+`}
+
+
+
+ Design principles for the generated image
+
+
+
+
+ Lessons from the most-shared platforms
+
+ We studied the platforms whose links travel the most across the web (see
+ the Benchmark page). The winners — YouTube, Reddit, GitHub — all share
+ rich, open, contextual cards; the gated ones (Instagram, TikTok) get
+ shared less because their links look broken. What that means for us:
+
+
+
+
+
+ Caching & invalidation
+
+ Crawlers cache aggressively: Facebook and LinkedIn hold previews for{' '}
+ up to ~7 days, and they fetch your tags exactly once per
+ URL. Two consequences:
+
+
+
+
+
+ Recommended rollout order
+
+
+
+
+ Sources
+
+
+);
+
+const meta: Meta = {
+ title: 'Open Graph/3. Guidelines & Research',
+ component: Guidelines,
+ parameters: { layout: 'fullscreen' },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Research: Story = { name: 'Guidelines & research' };
diff --git a/packages/storybook/stories/open-graph/Overview.stories.tsx b/packages/storybook/stories/open-graph/Overview.stories.tsx
new file mode 100644
index 00000000000..1f529cb12b4
--- /dev/null
+++ b/packages/storybook/stories/open-graph/Overview.stories.tsx
@@ -0,0 +1,161 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { RecommendedOg } from './dailyOgImages';
+import {
+ Bullets,
+ Divider,
+ Heading,
+ Muted,
+ Page,
+ PageHeader,
+} from './ogStoryLayout';
+
+const SANS =
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
+
+const TOC: Array<{ n: string; name: string; what: string }> = [
+ {
+ n: '1',
+ name: 'Current Share Previews',
+ what: 'Every share type as it unfurls today on 7 platforms, with a meta-tag inspector.',
+ },
+ {
+ n: '2',
+ name: 'Current vs Recommended',
+ what: 'Side-by-side mock-ups and the fix for each surface.',
+ },
+ {
+ n: '3',
+ name: 'Guidelines & Research',
+ what: 'Platform spec cheat-sheet, tags to emit, caching, priorities.',
+ },
+ {
+ n: '4',
+ name: 'X (Twitter) Deep Dive',
+ what: 'How X actually renders links and how to win the surface where the image is everything.',
+ },
+ {
+ n: '5',
+ name: 'Recommended Template Spec',
+ what: 'The real @vercel/og template, tokens, text rules and rollout.',
+ },
+ {
+ n: '6',
+ name: 'How the Best Platforms Share',
+ what: 'What GitHub, X, Reddit & co. do — and which platforms drive the most shares.',
+ },
+ {
+ n: '7',
+ name: 'Link Copy, Metadata & Behavior',
+ what: 'Everything outside the image: titles, descriptions, favicon, URLs, share text.',
+ },
+];
+
+const Overview = (): React.ReactElement => (
+
+
+ When a developer shares a daily.dev link — an article, their profile, a
+ squad, an invite — that preview is often the first time someone sees us.
+ It’s free, high-intent distribution, and right now we’re leaving most of
+ it on the table: titles get truncated by a “| daily.dev” suffix, several
+ share types fall back to one generic image, and nothing feels distinctly
+ ours. This initiative fixes the whole surface — the image, the copy, and
+ the link behavior — across every place we’re shared.
+
+
+
+
+
+
+ Why this matters now
+
+
+
+
+ What’s in this section
+
+ {TOC.map((t) => (
+
+
+ {t.n}
+
+
+
+ {t.name}
+
+
+ {t.what}
+
+
+
+ ))}
+
+
+ Open the numbered pages under “Open Graph” in the sidebar in order. Start
+ with Current vs Recommended (2) if you just want to see the before/after.
+
+
+);
+
+const meta: Meta = {
+ title: 'Open Graph/0. Overview',
+ component: Overview,
+ parameters: { layout: 'fullscreen' },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Start: Story = { name: 'Start here' };
diff --git a/packages/storybook/stories/open-graph/RecommendedSpec.stories.tsx b/packages/storybook/stories/open-graph/RecommendedSpec.stories.tsx
new file mode 100644
index 00000000000..d64444e3e69
--- /dev/null
+++ b/packages/storybook/stories/open-graph/RecommendedSpec.stories.tsx
@@ -0,0 +1,470 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { RecommendedOg } from './dailyOgImages';
+import {
+ Bullets,
+ CodeBlock,
+ Divider,
+ Heading,
+ Muted,
+ Page,
+ PageHeader,
+ SpecTable,
+} from './ogStoryLayout';
+
+const SANS =
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
+
+const Callout = ({
+ n,
+ top,
+ left,
+}: {
+ n: number;
+ top: string;
+ left: string;
+}): React.ReactElement => (
+
+ {n}
+
+);
+
+const Anatomy = (): React.ReactElement => (
+
+);
+
+const TEMPLATE_CODE = `// share-card.tsx — Satori (@vercel/og) template. Flexbox + inline styles only.
+// One component, driven by \`kind\`. No grid, no external CSS.
+
+type Kind =
+ | 'article' | 'shared' | 'profile' | 'squad'
+ | 'invite' | 'tag' | 'comment' | 'source' | 'generic';
+
+interface ShareCardProps {
+ kind: Kind;
+ title: string; // already truncated to <= 3 lines server-side
+ subtitle?: string; // 0–2 lines
+ identity?: { name: string; handle?: string; avatar?: string }; // top-left
+ upvotes?: string; // article / shared / comment -> engagement bar
+ comments?: string;
+ meta?: string; // other kinds -> glass meta pill ("4,210 members")
+ cover?: string; // absolute https URL; drives backdrop + art thumbnail
+}
+
+export function ShareCard(p: ShareCardProps) {
+ const engage = ['article', 'shared', 'comment'].includes(p.kind);
+ return (
+
+ {/* Ambient backdrop — a pre-blurred cover (Satori can't blur at runtime),
+ else a brand-gradient glow. A dark gradient on top keeps text legible. */}
+ {p.cover
+ ?

+ :
}
+
+
+ {/* Top bar: identity (left) + the real daily.dev logo (right) */}
+
+
+
+
+
+ {/* Left content: headline + subtitle on top, the glass bar pinned to the bottom */}
+
+
+
{p.title}
+ {p.subtitle &&
{p.subtitle}
}
+
+ {/* Glass bar — pre-composited translucent fill (no runtime backdrop-blur in Satori) */}
+
+ {engage
+ ? <>>
+ : {p.meta}}
+
+
+
+ {/* Art bottom-right: cover thumbnail, avatar, or brand tile (radius 54) */}
+
+
+ );
+}
+
+// Satori clamps text with the webkit box trio:
+const clamp = (lines: number) =>
+ ({ display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: lines, overflow: 'hidden' });`;
+
+const ROUTE_CODE = `// /api/share route — edge runtime. Generates 1200×630 once, then CDN-cached.
+import { ImageResponse } from '@vercel/og'; // or 'next/og'
+
+export const runtime = 'edge';
+
+const FONT = (w: string) =>
+ fetch(\`https://og.daily.dev/fonts/Inter-\${w}.ttf\`).then((r) => r.arrayBuffer());
+
+export async function GET(req: Request) {
+ const u = new URL(req.url);
+ const kind = (u.searchParams.get('kind') ?? 'generic') as Kind;
+ const props = await resolveProps(kind, u.searchParams); // fetch post/squad/user, truncate title
+
+ const [regular, bold, extrabold] = await Promise.all([FONT('Regular'), FONT('Bold'), FONT('ExtraBold')]);
+
+ return new ImageResponse(, {
+ width: 1200,
+ height: 630,
+ fonts: [
+ { name: 'Inter', data: regular, weight: 400, style: 'normal' },
+ { name: 'Inter', data: bold, weight: 700, style: 'normal' },
+ { name: 'Inter', data: extrabold, weight: 800, style: 'normal' },
+ ],
+ headers: {
+ // version the URL with ?v={contentHash}; then this immutable cache is safe.
+ 'cache-control': 'public, immutable, no-transform, max-age=31536000',
+ },
+ });
+}`;
+
+const META_CODE = `// Webapp side — point og:image at the versioned generator and keep tags lean.
+const v = post.contentHash; // bust X/LinkedIn/FB caches on edit or redesign
+const img = \`https://og.daily.dev/api/share?kind=article&id=\${post.id}&v=\${v}\`;
+
+const seo: NextSeoProps = {
+ title: post.title, // NO "| daily.dev" suffix
+ description: clamp(post.summary, 110),
+ openGraph: {
+ type: 'article',
+ title: post.title,
+ description: clamp(post.summary, 110),
+ siteName: 'daily.dev',
+ images: [{ url: img, width: 1200, height: 630, alt: post.title }],
+ },
+ twitter: { cardType: 'summary_large_image', site: '@dailydotdev' },
+ additionalMetaTags: [
+ { name: 'twitter:image:alt', content: post.title },
+ ...(post.author?.twitter ? [{ name: 'twitter:creator', content: post.author.twitter }] : []),
+ { name: 'twitter:label1', content: 'Author' }, { name: 'twitter:data1', content: post.source.name },
+ { name: 'twitter:label2', content: 'Reading time' }, { name: 'twitter:data2', content: \`\${post.readTime} min\` },
+ ],
+};`;
+
+const RecommendedSpec = (): React.ReactElement => (
+
+
+ A single, contextual template that renders every share type — driven by a{' '}
+ kind parameter — plus the text/naming rules and the actual{' '}
+ @vercel/og (Satori) implementation it should ship as on{' '}
+ og.daily.dev. The goal: a daily.dev link is recognizable at a
+ glance on any platform, and reads perfectly even on X where the image is
+ the entire message.
+
+
+ Anatomy of the card
+
+
+
+ -
+ Identity — source / author / sharer top-left, where
+ the context lives (a small avatar + name).
+
+ -
+ daily.dev logo — top-right, the real wordmark;
+ present but secondary, never competes with the message.
+
+ -
+ Headline — the hero. ≤ 3 lines, the only thing X
+ shows; sits over an ambient backdrop derived from the cover.
+
+ -
+ Engagement bar — avocado upvote + comment in a
+ glass/blur pill; the ownable daily.dev signal.
+
+ -
+ Cover art — rounded thumbnail bottom-right (becomes
+ an avatar/tile for profiles, squads, tags…).
+
+
+
+
+
+
+ Design tokens
+
+
+
+
+ The text system — titles, descriptions, names
+
+ The copy matters as much as the picture. Three global rules, then a
+ pattern per surface.
+
+
+
+
+
+
+ 1 · The Satori template
+
+ One component, switched on kind. Satori supports flexbox,
+ inline styles, gradients, borders and the webkit line-clamp trio — no CSS
+ grid, no stylesheets. Fonts must be supplied explicitly (TTF/OTF/WOFF; not
+ WOFF2).
+
+ {TEMPLATE_CODE}
+
+ 2 · The edge route
+
+ Generate once at the edge, then serve immutable from the CDN. The{' '}
+ ?v= content hash is what lets us safely use a one-year
+ immutable cache while still busting stale previews on edit.
+
+ {ROUTE_CODE}
+
+ 3 · Meta tags on the webapp
+
+ Point og:image at the versioned generator and keep the tags
+ lean. Note: no title suffix, explicit width/height, alt text, and the
+ Slack-only label/data pairs.
+
+ {META_CODE}
+
+
+
+ Rollout against the existing code
+
+
+
+
+ Sources
+
+
+);
+
+const meta: Meta = {
+ title: 'Open Graph/5. Recommended Template Spec',
+ component: RecommendedSpec,
+ parameters: { layout: 'fullscreen' },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Spec: Story = { name: 'Template spec & code' };
diff --git a/packages/storybook/stories/open-graph/XDeepDive.stories.tsx b/packages/storybook/stories/open-graph/XDeepDive.stories.tsx
new file mode 100644
index 00000000000..0b883bbdf2c
--- /dev/null
+++ b/packages/storybook/stories/open-graph/XDeepDive.stories.tsx
@@ -0,0 +1,376 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import type { ReactNode } from 'react';
+import { XCard } from './platformCards';
+import type { OgData } from './platformCards';
+import { RecommendedOg } from './dailyOgImages';
+import { USE_CASES } from './useCases';
+import {
+ Bullets,
+ Divider,
+ Heading,
+ Muted,
+ Page,
+ PageHeader,
+ SpecTable,
+ TwoCol,
+} from './ogStoryLayout';
+
+const SANS =
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
+
+const article = USE_CASES.find((u) => u.id === 'article');
+
+const labelStyle: React.CSSProperties = {
+ fontFamily: SANS,
+ fontSize: 12,
+ fontWeight: 700,
+ textTransform: 'uppercase',
+ letterSpacing: 0.6,
+ color: '#71717a',
+ marginBottom: 8,
+};
+
+/** Realistic X timeline chrome wrapped around a link card. */
+const Tweet = ({
+ data,
+ tweetText,
+}: {
+ data: OgData;
+ tweetText: string;
+}): React.ReactElement => (
+
+
+
+
+
+
+ Ido Shamun
+
+
+ @idoshamun · 2h
+
+
+
+ {tweetText}
+
+
+
+ 💬 24
+ 🔁 108
+ ♥ 642
+ 📊 23K
+
+
+
+
+);
+
+/** Overlay the 80% safe area + rounded-corner danger zones on an image. */
+const SafeAreaFrame = ({
+ children,
+}: {
+ children: ReactNode;
+}): React.ReactElement => (
+
+
{children}
+ {/* 80% safe area */}
+
+
+ 80% safe area
+
+ {/* corner danger markers */}
+ {['0 0 auto auto', '0 auto auto 0', 'auto 0 0 auto', 'auto auto 0 0'].map(
+ (inset) => (
+
+ ),
+ )}
+
+);
+
+const XDeepDive = (): React.ReactElement => (
+
+
+ X is the highest-leverage surface for developer sharing — and the most
+ unforgiving. On a modern summary_large_image card, X shows{' '}
+ only the image and a small domain pill: no title, no
+ description, no body text outside the picture. Whatever you want a reader
+ to know has to live inside the 1200×630 frame. This page covers exactly
+ how X behaves and how our generated image should be built for it.
+
+
+ Why X is where the image has to do all the work
+
+
+
+
+ The same link, in an X timeline
+
+ Today the card leads with the publisher’s cover and a small daily.dev mark
+ — the headline is barely legible and the brand is easy to miss. The
+ recommended card bakes the headline, author and brand into the image, so
+ it reads at a glance even at timeline scale.
+
+ {article && (
+
+
+ }
+ right={
+
+ }
+ />
+
+ )}
+
+
+
+ Design for the X thumbnail, not the full-size image
+
+ Keep everything that matters inside the centered{' '}
+ 80% safe area, and out of the four corners (rounded +
+ domain pill). Headline ≥ 48px at 1200px width, bold, with a hard contrast
+ floor so it survives next to vibrant posts in the feed.
+
+
+
+
+
+ X-specific tag & image spec
+
+
+
+
+ Large vs summary card
+
+ We should always request summary_large_image. The small{' '}
+ summary card (square thumbnail + text) is the weak fallback —
+ shown here only so the difference is obvious. With the small card the
+ image shrinks to a tile and the text X shows is our og:title/description,
+ so those still need to read well.
+
+ {article && (
+
+
+
summary_large_image (use this)
+
+
+
+
+ )}
+
+
+
+ X recommendations
+
+
+
+
+ Sources
+
+
+);
+
+const meta: Meta = {
+ title: 'Open Graph/4. X (Twitter) Deep Dive',
+ component: XDeepDive,
+ parameters: { layout: 'fullscreen' },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Optimizing: Story = { name: 'Optimizing for X' };
diff --git a/packages/storybook/stories/open-graph/cover.tsx b/packages/storybook/stories/open-graph/cover.tsx
new file mode 100644
index 00000000000..6377a4aaac1
--- /dev/null
+++ b/packages/storybook/stories/open-graph/cover.tsx
@@ -0,0 +1,454 @@
+import React from 'react';
+import type { CSSProperties, ReactNode } from 'react';
+import LogoIcon from '@dailydotdev/shared/src/svg/LogoIcon';
+import LogoText from '@dailydotdev/shared/src/svg/LogoText';
+import { UpvoteIcon } from '@dailydotdev/shared/src/components/icons/Upvote';
+import { DiscussIcon } from '@dailydotdev/shared/src/components/icons/Discuss';
+
+/**
+ * The locked "Layout A — clean" design system, self-contained. Provides the
+ * shared atoms (logo, glass engagement/stat bars, title/subtitle, art tiles)
+ * and the generalized `OgCover` used by every recommended share-card mock-up:
+ * identity top-left, daily.dev logo top-right, title + meta on the left, and
+ * cover art bottom-right. `RecommendedOg` in `dailyOgImages.tsx` is the adapter
+ * every story page consumes.
+ */
+
+export const SANS =
+ '-apple-system, "Helvetica Neue", Helvetica, Inter, Arial, "Segoe UI", system-ui, sans-serif';
+
+export const INK = '#0B0E13';
+export const CABBAGE = '#CE3DF3';
+const ONION = '#8A63F4';
+export const TEXT = '#FFFFFF';
+export const TERTIARY = '#A6AEBF';
+export const SECONDARY = '#D7DCE6';
+export const GRAD = `linear-gradient(135deg, ${CABBAGE}, ${ONION})`;
+export const white = {
+ ['--theme-text-primary' as string]: '#FFFFFF',
+} as CSSProperties;
+
+const COVER =
+ 'https://media.daily.dev/image/upload/s--P4t4XyoV--/f_auto/v1722860399/public/Placeholder%2001';
+
+export interface CoverData {
+ title: string;
+ source: string;
+ sourceColor: string;
+ readTime: string;
+ upvotes: string;
+ comments: string;
+ cover: string;
+}
+export const XD: CoverData = {
+ title: 'How we cut edge cold-starts by 90% with Rust and Wasm',
+ source: 'The Pragmatic Engineer',
+ sourceColor: '#FF8E3B',
+ readTime: '6m read',
+ upvotes: '312',
+ comments: '448',
+ cover: COVER,
+};
+
+export const clamp = (lines: number): CSSProperties => ({
+ display: '-webkit-box',
+ WebkitLineClamp: lines,
+ WebkitBoxOrient: 'vertical',
+ overflow: 'hidden',
+});
+
+export const Root = ({
+ children,
+}: {
+ children: ReactNode;
+}): React.ReactElement => (
+
+ {children}
+
+);
+
+export const Ambient = ({ src }: { src: string }): React.ReactElement => (
+ <>
+
+
+ >
+);
+
+export const Logo = (): React.ReactElement => (
+
+
+
+
+
+
+
+
+);
+
+const Stat = ({
+ Cmp,
+ count,
+}: {
+ Cmp: typeof UpvoteIcon;
+ count: string;
+}): React.ReactElement => (
+
+
+
+
+
+ {count}
+
+
+);
+
+// The glass/blur bar chrome — shared by the engagement bar and meta pills.
+export const GlassBar = ({
+ children,
+ style,
+}: {
+ children: ReactNode;
+ style?: CSSProperties;
+}): React.ReactElement => (
+
+ {children}
+
+);
+
+export const Actions = ({ d }: { d: CoverData }): React.ReactElement => (
+
+
+
+
+
+
+
+);
+
+export const MetaPill = ({ text }: { text: string }): React.ReactElement => (
+
+
+ {text}
+
+
+);
+
+export interface StatItem {
+ label: string;
+ Cmp: typeof UpvoteIcon;
+ value: string;
+ color?: string;
+}
+
+// Glass bar with several icon+number stats (profile reputation/streak/posts,
+// squad members/posts/upvotes) — same chrome as the engagement bar.
+export const StatBar = ({
+ stats,
+}: {
+ stats: StatItem[];
+}): React.ReactElement => (
+
+
+ {stats.map((s, i) => (
+
+ {i > 0 && (
+
+ )}
+
+
+
+
+
+ {s.value}
+
+
+
+ ))}
+
+
+);
+
+// White primary button (daily.dev primary on a dark surface).
+export const PrimaryButton = ({
+ children,
+}: {
+ children: ReactNode;
+}): React.ReactElement => (
+
+ {children}
+
+);
+
+// Headline/title for the generalized cover (any text, sized per use case).
+export const Title = ({
+ children,
+ size = 5.2,
+ lines = 3,
+}: {
+ children: ReactNode;
+ size?: number;
+ lines?: number;
+}): React.ReactElement => (
+
+ {children}
+
+);
+
+export const Subtitle = ({
+ children,
+}: {
+ children: ReactNode;
+}): React.ReactElement => (
+
+ {children}
+
+);
+
+// Brand backdrop for covers with no cover image (profile/squad/tag/etc.).
+const BrandBackdrop = (): React.ReactElement => (
+ <>
+
+
+ >
+);
+
+export interface OgCoverProps {
+ // When true, fills its positioned parent (e.g. a platform preview frame).
+ // Otherwise it is a self-sizing 1200×630 box.
+ fill?: boolean;
+ backdrop?: string;
+ eyebrow?: ReactNode;
+ title?: ReactNode;
+ subtitle?: ReactNode;
+ meta?: ReactNode;
+ art?: ReactNode;
+ logo?: ReactNode;
+ // Where the left content column starts (raise it for content-dense covers).
+ contentTop?: string;
+}
+
+/**
+ * The generalized Layout A — same clean structure for every share type:
+ * identity top-left, logo top-right, title + meta on the left, art bottom-right.
+ */
+export const OgCover = ({
+ fill = false,
+ backdrop,
+ eyebrow,
+ title,
+ subtitle,
+ meta,
+ art,
+ logo,
+ contentTop = '15.5cqw',
+}: OgCoverProps): React.ReactElement => {
+ const body = (
+ <>
+ {backdrop ? : }
+ {art && (
+
+ {art}
+
+ )}
+
+ {eyebrow}
+ {logo ?? }
+
+
+
+ {title}
+ {subtitle}
+
+ {meta &&
{meta}
}
+
+ >
+ );
+ if (fill) {
+ return (
+
+ {body}
+
+ );
+ }
+ return {body};
+};
diff --git a/packages/storybook/stories/open-graph/dailyOgImages.tsx b/packages/storybook/stories/open-graph/dailyOgImages.tsx
new file mode 100644
index 00000000000..f7d8bf78870
--- /dev/null
+++ b/packages/storybook/stories/open-graph/dailyOgImages.tsx
@@ -0,0 +1,826 @@
+import React from 'react';
+import type { CSSProperties } from 'react';
+import LogoIcon from '@dailydotdev/shared/src/svg/LogoIcon';
+import { UpvoteIcon } from '@dailydotdev/shared/src/components/icons/Upvote';
+import { UserIcon } from '@dailydotdev/shared/src/components/icons/User';
+import { SquadIcon } from '@dailydotdev/shared/src/components/icons/Squad';
+import { DocsIcon } from '@dailydotdev/shared/src/components/icons/Docs';
+import { DevPlusIcon } from '@dailydotdev/shared/src/components/icons/DevPlus';
+import {
+ OgCover,
+ Ambient,
+ Logo,
+ Title,
+ Subtitle,
+ MetaPill,
+ Actions,
+ StatBar,
+ PrimaryButton,
+ GlassBar,
+ GRAD,
+ CABBAGE,
+ INK,
+ SANS,
+ TEXT,
+ SECONDARY,
+ TERTIARY,
+ clamp,
+ white,
+ XD,
+} from './cover';
+import type { StatItem } from './cover';
+
+// ===========================================================================
+// RECOMMENDED — one unified, contextual system
+// ===========================================================================
+
+export type RecommendedKind =
+ | 'article'
+ | 'shared'
+ | 'profile'
+ | 'squad'
+ | 'invite'
+ | 'tag'
+ | 'comment'
+ | 'plus'
+ | 'generic';
+
+interface RecommendedProps {
+ kind?: RecommendedKind;
+ title?: string;
+ subtitle?: string;
+ cover?: string;
+ name?: string;
+ handle?: string;
+ avatarSrc?: string;
+ meta?: string;
+ sharer?: string;
+ upvotes?: string;
+ comments?: string;
+ // profile stats
+ reputation?: string;
+ streak?: string;
+ posts?: string;
+ reads?: string;
+ tags?: string[];
+ sources?: string[]; // favorite source logo URLs ("reads the most")
+ // squad stats
+ members?: string;
+ // invite / referral / plus
+ cta?: string; // white primary button label
+ count?: string; // prominent count (number shows in cabbage)
+ countWord?: string; // e.g. "developers"
+ mascot?: string; // Charm illustration URL for the art slot
+ square?: boolean; // render the square (1:1) summary-card variant
+}
+
+// Square (1:1) variant — responsive fallback for summary cards. The headline
+// is dropped (unreadable at thumbnail size, and it's already in the link);
+// keep only what reads: the cover art, the daily.dev logo, and the source.
+const SquareCover = ({
+ cover,
+ source,
+}: {
+ cover?: string;
+ source?: string;
+}): React.ReactElement => (
+
+ {cover ? (
+
+ ) : (
+
+ )}
+ {/* top bar: source (left) + logo (right) */}
+
+ {source ? (
+
+
+
+ {source}
+
+
+ ) : (
+
+ )}
+
+
+ {/* centered cover art — the hero of the square */}
+ {cover && (
+
+

+
+ )}
+
+);
+
+// ---- Layout A building blocks for the recommended template ----------------
+const RArt = ({
+ src,
+ circle = false,
+}: {
+ src: string;
+ circle?: boolean;
+}): React.ReactElement => (
+
+);
+const RTile = ({
+ children,
+ circle = false,
+}: {
+ children: React.ReactNode;
+ circle?: boolean;
+}): React.ReactElement => (
+
+ {children}
+
+);
+const RMark = (): React.ReactElement => (
+
+
+
+);
+const REyebrow = ({
+ text,
+ sub,
+ src,
+ dot = false,
+}: {
+ text: string;
+ sub?: string;
+ src?: string;
+ dot?: boolean;
+}): React.ReactElement => (
+
+ {src && (
+

+ )}
+ {!src && dot && (
+
+ )}
+
+ {text}
+
+ {sub && (
+
+ · {sub}
+
+ )}
+
+);
+
+// Charm mascot in the art slot — bigger, anchored bottom-right and dropped a
+// bit lower so it fills the corner instead of floating small. (The art slot is
+// position: absolute, so this positions against it.)
+const RMascot = ({ src }: { src: string }): React.ReactElement => (
+
+);
+
+// Art fallback chain shared by invite/generic: Charm mascot → circular cover
+// → the brand tile when neither is available.
+const mascotOrCoverArt = (mascot?: string, cover?: string): React.ReactNode => {
+ if (mascot) {
+ return ;
+ }
+ if (cover) {
+ return ;
+ }
+ return (
+
+
+
+ );
+};
+
+// Prominent count line — the number pops in cabbage, with a leading icon.
+const ProminentCount = ({
+ count,
+ word,
+}: {
+ count: string;
+ word?: string;
+}): React.ReactElement => (
+
+
+
+
+
+ {count}
+ {word ? ` ${word}` : ''}
+
+
+);
+
+// Community proof — a stack of real member avatars + the count, the way the
+// squad/source pages surface the community. Used for source + tag. Faces are
+// real daily.dev developer avatars.
+const COMMUNITY_FACES = [
+ 'https://media.daily.dev/image/upload/s--FcI3RdS1--/f_auto/v1745335145/avatars/avatar_R9RafYjp15h3mJ9XdIkhy',
+ 'https://media.daily.dev/image/upload/s--AVEMGQgE--/f_auto/v1744349812/avatars/avatar_RVvUzGofSIHGyTxdjqDN1',
+ 'https://media.daily.dev/image/upload/s--CwdXky60--/f_auto/v1733031652/avatars/avatar_rmFJzNXUNPh163VaQkmF0',
+];
+const Community = ({ count }: { count: string }): React.ReactElement => (
+
+
+
+ {COMMUNITY_FACES.map((src, i) => (
+
+ ))}
+
+
+ {count}
+
+
+
+);
+
+// ---- Developer profile — mirrors the real DevCard ------------------------
+// Rounded avatar with a clean 1px hairline border (subtle, low-opacity white —
+// a soft edge rather than a heavy band).
+const TiltAvatar = ({
+ label,
+ src,
+}: {
+ label?: string;
+ src?: string;
+}): React.ReactElement => {
+ const base: CSSProperties = {
+ width: '100%',
+ height: '100%',
+ borderRadius: '8cqw',
+ border: '1px solid rgba(255,255,255,0.2)',
+ boxSizing: 'border-box',
+ boxShadow: '0 4cqw 11cqw rgba(0,0,0,0.5)',
+ };
+ return src ? (
+
+ ) : (
+
+
+ {label}
+
+
+ );
+};
+
+// The DevCard stats bar: 3 sections, each icon + number + label.
+const ProfileStatSection = ({
+ value,
+ label,
+}: {
+ value: string;
+ label: string;
+}): React.ReactElement => (
+
+
+ {value}
+
+
+ {label}
+
+
+);
+const ProfileStats = ({
+ reputation,
+ streak,
+ reads,
+}: {
+ reputation?: string;
+ streak?: string;
+ reads?: string;
+}): React.ReactElement => {
+ const divider = (
+
+ );
+ return (
+
+ {reputation && (
+
+ )}
+ {reputation && streak && divider}
+ {streak &&
}
+ {streak && reads && divider}
+ {reads &&
}
+
+ );
+};
+
+const TagChips = ({ tags }: { tags: string[] }): React.ReactElement => (
+
+ {tags.map((t) => (
+
+ #{t}
+
+ ))}
+
+);
+
+// Favorite sources the developer reads most — a "Reads" label + source logos
+// (rounded squares on white), like the DevCard footer.
+const SourceLogos = ({ logos }: { logos: string[] }): React.ReactElement => (
+
+
+ Reads from
+
+
+ {logos.map((l) => (
+
+
+
+ ))}
+
+
+);
+
+/**
+ * The recommended template = the locked "Layout A — clean" cover, one system
+ * for every share type. Article/shared/comment keep the real engagement bar
+ * (avocado upvote + comment); other types show stats, a meta pill, or a CTA.
+ */
+export const RecommendedOg = ({
+ kind = 'article',
+ title = 'How to build a dynamic Open Graph image pipeline at the edge',
+ subtitle,
+ cover,
+ name,
+ handle,
+ avatarSrc,
+ meta,
+ sharer,
+ upvotes = '312',
+ comments = '448',
+ reputation,
+ streak,
+ posts,
+ reads,
+ tags,
+ sources,
+ members,
+ cta,
+ count,
+ countWord,
+ mascot,
+ square = false,
+}: RecommendedProps): React.ReactElement => {
+ if (square) {
+ return ;
+ }
+ const engagement = ;
+ const pill = meta ? : undefined;
+ const sub = subtitle ? {subtitle} : undefined;
+ const ctaNode = cta ? {cta} : undefined;
+
+ const squadStats: StatItem[] = [];
+ if (members) {
+ squadStats.push({ label: 'members', Cmp: SquadIcon, value: members });
+ }
+ if (posts) {
+ squadStats.push({ label: 'posts', Cmp: DocsIcon, value: posts });
+ }
+ if (upvotes) {
+ squadStats.push({ label: 'upvotes', Cmp: UpvoteIcon, value: upvotes });
+ }
+
+ switch (kind) {
+ case 'shared':
+ return (
+
+ }
+ title={{title}}
+ subtitle={name ? {name} : undefined}
+ meta={engagement}
+ art={cover ? : undefined}
+ />
+ );
+ case 'comment':
+ // No art tile — the quote gets the full width.
+ return (
+
+ }
+ title={{title}}
+ subtitle={meta ? {meta} : undefined}
+ meta={engagement}
+ />
+ );
+ case 'profile':
+ // One ordered column with equal gaps: name/role → stats → tags → sources.
+ return (
+ }
+ title={
+
+
+
+ {title}
+
+ {subtitle && {subtitle}}
+
+ {(reputation || streak || reads) && (
+
+
+
+ )}
+ {sources?.length ? : null}
+ {tags?.length ? : null}
+
+ }
+ art={}
+ />
+ );
+ case 'squad':
+ return (
+ }
+ title={{title}}
+ subtitle={sub}
+ meta={squadStats.length ? : pill}
+ art={
+ cover ? (
+
+ ) : (
+
+
+ {(title ?? 'S')[0]}
+
+
+ )
+ }
+ />
+ );
+ case 'tag':
+ return (
+ }
+ title={{title}}
+ subtitle={sub}
+ meta={ctaNode ?? (meta ? : undefined)}
+ art={
+ mascot ? (
+
+ ) : (
+
+
+ #
+
+
+ )
+ }
+ />
+ );
+ case 'invite':
+ return (
+
+ }
+ title={{title}}
+ subtitle={
+ count ? : sub
+ }
+ meta={ctaNode ?? pill}
+ art={mascotOrCoverArt(mascot, cover)}
+ />
+ );
+ case 'plus':
+ return (
+
+
+
+
+
+ daily.dev Plus
+
+
+ }
+ title={{title}}
+ subtitle={sub}
+ meta={ctaNode ?? (meta ? : undefined)}
+ art={
+ mascot ? (
+
+ ) : (
+
+
+
+
+
+ )
+ }
+ />
+ );
+ case 'generic':
+ return (
+ }
+ title={{title}}
+ subtitle={sub}
+ meta={ctaNode ?? (meta ? : undefined)}
+ art={mascotOrCoverArt(mascot, cover)}
+ />
+ );
+ default:
+ return (
+ }
+ title={{title}}
+ meta={engagement}
+ art={cover ? : undefined}
+ />
+ );
+ }
+};
diff --git a/packages/storybook/stories/open-graph/ogStoryLayout.tsx b/packages/storybook/stories/open-graph/ogStoryLayout.tsx
new file mode 100644
index 00000000000..71405b036eb
--- /dev/null
+++ b/packages/storybook/stories/open-graph/ogStoryLayout.tsx
@@ -0,0 +1,322 @@
+import React from 'react';
+import type { CSSProperties, ReactNode } from 'react';
+
+/**
+ * Presentational scaffolding shared by the Open Graph review stories. Uses the
+ * daily.dev theme CSS variables so it follows Storybook's light/dark toggle,
+ * but keeps everything provider-free so the stories render standalone.
+ */
+
+const SANS =
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
+
+export const Page = ({
+ children,
+}: {
+ children: ReactNode;
+}): React.ReactElement => (
+
+ {children}
+
+);
+
+export const PageHeader = ({
+ eyebrow,
+ title,
+ children,
+}: {
+ eyebrow: string;
+ title: string;
+ children?: ReactNode;
+}): React.ReactElement => (
+
+
+ {eyebrow}
+
+
+ {title}
+
+ {children && (
+
+ {children}
+
+ )}
+
+);
+
+export const Heading = ({
+ children,
+ badge,
+}: {
+ children: ReactNode;
+ badge?: string;
+}): React.ReactElement => (
+
+
+ {children}
+
+ {badge && (
+
+ {badge}
+
+ )}
+
+);
+
+export const Muted = ({
+ children,
+ style,
+}: {
+ children: ReactNode;
+ style?: CSSProperties;
+}): React.ReactElement => (
+
+ {children}
+
+);
+
+export const Bullets = ({
+ items,
+ tone = 'neutral',
+ title,
+}: {
+ items: string[];
+ tone?: 'neutral' | 'bad' | 'good';
+ title?: string;
+}): React.ReactElement => {
+ const color = { bad: '#d4342c', good: '#1f9d55', neutral: 'inherit' }[tone];
+ const mark = { bad: '✗', good: '✓', neutral: '•' }[tone];
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {items.map((item) => (
+ -
+
+ {mark}
+
+ {item}
+
+ ))}
+
+
+ );
+};
+
+export const Divider = (): React.ReactElement => (
+
+);
+
+export const TwoCol = ({
+ left,
+ right,
+ leftLabel,
+ rightLabel,
+}: {
+ left: ReactNode;
+ right: ReactNode;
+ leftLabel: string;
+ rightLabel: string;
+}): React.ReactElement => (
+
+ {[
+ { label: leftLabel, node: left, tone: '#d4342c' },
+ { label: rightLabel, node: right, tone: '#1f9d55' },
+ ].map((col) => (
+
+
+ {col.label}
+
+ {col.node}
+
+ ))}
+
+);
+
+const specCell: CSSProperties = {
+ padding: '8px 12px',
+ fontSize: 13,
+ textAlign: 'left',
+ borderBottom: '1px solid var(--theme-divider-tertiary)',
+ verticalAlign: 'top',
+};
+const specHead: CSSProperties = {
+ ...specCell,
+ fontWeight: 700,
+ color: 'var(--theme-text-primary)',
+ borderBottom: '2px solid var(--theme-divider-secondary)',
+};
+
+export const SpecTable = ({
+ columns,
+ rows,
+}: {
+ columns: string[];
+ rows: string[][];
+}): React.ReactElement => (
+
+
+
+
+ {columns.map((c) => (
+ |
+ {c}
+ |
+ ))}
+
+
+
+ {rows.map((r) => (
+
+ {r.map((c, ci) => (
+ |
+ {c}
+ |
+ ))}
+
+ ))}
+
+
+
+);
+
+export const CodeBlock = ({
+ children,
+}: {
+ children: ReactNode;
+}): React.ReactElement => (
+
+ {children}
+
+);
diff --git a/packages/storybook/stories/open-graph/platformCards.tsx b/packages/storybook/stories/open-graph/platformCards.tsx
new file mode 100644
index 00000000000..52d30578c56
--- /dev/null
+++ b/packages/storybook/stories/open-graph/platformCards.tsx
@@ -0,0 +1,614 @@
+import React from 'react';
+import type { CSSProperties, ReactNode } from 'react';
+
+/**
+ * Faithful mock-ups of how a shared link unfurls on each major platform.
+ * These intentionally use raw inline styles (not the daily.dev design tokens)
+ * because the goal is to reproduce the *third-party* chrome of X, LinkedIn,
+ * Facebook, Slack, Discord and the messengers as closely as possible so the
+ * team can review our Open Graph output in a realistic context.
+ */
+
+export type CardType = 'summary' | 'summary_large_image';
+
+export interface OgData {
+ /** Host shown in the preview, e.g. "app.daily.dev". */
+ domain: string;
+ /** og:url path, shown in the meta table only. */
+ path?: string;
+ /** og:title — the headline platforms display. */
+ title: string;
+ /** og:description. */
+ description: string;
+ /** og:image URL. Used when imageNode is not provided. */
+ image?: string;
+ /** Render a faithful mock of a dynamically generated image instead of
. */
+ imageNode?: ReactNode;
+ /** Square-ratio variant of the generated image, for summary cards. */
+ squareNode?: ReactNode;
+ /** twitter:card. */
+ cardType: CardType;
+ /** og:site_name. */
+ siteName?: string;
+ /** og:image:alt / twitter:image:alt. */
+ imageAlt?: string;
+ /** twitter:site handle, e.g. "@dailydotdev". */
+ twitterSite?: string;
+}
+
+const SANS =
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
+
+const clamp = (lines: number): CSSProperties => ({
+ display: '-webkit-box',
+ WebkitLineClamp: lines,
+ WebkitBoxOrient: 'vertical',
+ overflow: 'hidden',
+});
+
+const Favicon = ({ size = 16 }: { size?: number }): React.ReactElement => (
+
+ d.
+
+);
+
+const ImageFrame = ({
+ data,
+ radius = 0,
+ ratio = 1200 / 630,
+}: {
+ data: OgData;
+ radius?: number;
+ ratio?: number;
+}): React.ReactElement => (
+
+ {data.imageNode ? (
+
{data.imageNode}
+ ) : (
+

+ )}
+
+);
+
+const SquareThumb = ({
+ data,
+ size,
+}: {
+ data: OgData;
+ size: number;
+}): React.ReactElement => (
+
+ {data.squareNode ? (
+ // A dedicated square-ratio design (logo + cover art + source, no title).
+
{data.squareNode}
+ ) : (
+ // Fallback: keep the 1.91:1 design intact and letterbox it into the
+ // square so the whole card still reads — rather than squishing it.
+
+ {data.imageNode ? (
+
{data.imageNode}
+ ) : (
+

+ )}
+
+ )}
+
+);
+
+// ---------------------------------------------------------------------------
+// X / Twitter
+// ---------------------------------------------------------------------------
+export const XCard = ({ data }: { data: OgData }): React.ReactElement => {
+ if (data.cardType === 'summary') {
+ return (
+
+
+
+
{data.domain}
+
+ {data.title}
+
+
+ {data.description}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {data.domain}
+
+
+ );
+};
+
+// ---------------------------------------------------------------------------
+// LinkedIn
+// ---------------------------------------------------------------------------
+export const LinkedInCard = ({
+ data,
+}: {
+ data: OgData;
+}): React.ReactElement => (
+
+
+
+
+ {data.title}
+
+
+ {data.domain}
+
+
+
+);
+
+// ---------------------------------------------------------------------------
+// Facebook
+// ---------------------------------------------------------------------------
+export const FacebookCard = ({
+ data,
+}: {
+ data: OgData;
+}): React.ReactElement => (
+
+
+
+
+ {data.domain}
+
+
+ {data.title}
+
+
+ {data.description}
+
+
+
+);
+
+// ---------------------------------------------------------------------------
+// Slack
+// ---------------------------------------------------------------------------
+export const SlackCard = ({ data }: { data: OgData }): React.ReactElement => (
+
+
+
+
+ {data.siteName || 'daily.dev'}
+
+
+
+ {data.title}
+
+
+ {data.description}
+
+
+
+
+
+);
+
+// ---------------------------------------------------------------------------
+// Discord
+// ---------------------------------------------------------------------------
+export const DiscordCard = ({ data }: { data: OgData }): React.ReactElement => (
+
+
+ {data.siteName || 'daily.dev'}
+
+
+ {data.title}
+
+
+ {data.description}
+
+
+
+
+
+);
+
+// ---------------------------------------------------------------------------
+// WhatsApp (received bubble)
+// ---------------------------------------------------------------------------
+export const WhatsAppCard = ({
+ data,
+}: {
+ data: OgData;
+}): React.ReactElement => (
+
+
+
+
+ {data.title}
+
+
+ {data.description}
+
+
+ {data.domain}
+
+
+
+);
+
+// ---------------------------------------------------------------------------
+// iMessage (received bubble)
+// ---------------------------------------------------------------------------
+export const IMessageCard = ({
+ data,
+}: {
+ data: OgData;
+}): React.ReactElement => (
+
+
+
+
+ {data.title}
+
+
+ {data.domain}
+
+
+
+);
+
+// ---------------------------------------------------------------------------
+// Platform grid — renders one preview per platform for a given OgData.
+// ---------------------------------------------------------------------------
+const PLATFORMS: Array<{ label: string; Card: typeof XCard }> = [
+ { label: 'X / Twitter', Card: XCard },
+ { label: 'LinkedIn', Card: LinkedInCard },
+ { label: 'Facebook', Card: FacebookCard },
+ { label: 'Slack', Card: SlackCard },
+ { label: 'Discord', Card: DiscordCard },
+ { label: 'WhatsApp', Card: WhatsAppCard },
+ { label: 'iMessage', Card: IMessageCard },
+];
+
+const platformLabelStyle: CSSProperties = {
+ fontFamily: SANS,
+ fontSize: 12,
+ fontWeight: 700,
+ textTransform: 'uppercase',
+ letterSpacing: 0.6,
+ color: '#71717a',
+ marginBottom: 8,
+};
+
+export const PlatformGrid = ({
+ data,
+}: {
+ data: OgData;
+}): React.ReactElement => (
+
+ {PLATFORMS.map(({ label, Card }) => (
+
+ ))}
+
+);
+
+// ---------------------------------------------------------------------------
+// Meta-tag inspector table with per-platform length warnings.
+// ---------------------------------------------------------------------------
+const LIMITS = { title: 60, description: 110 };
+
+const Row = ({
+ tag,
+ value,
+ limit,
+}: {
+ tag: string;
+ value?: string;
+ limit?: number;
+}): React.ReactElement => {
+ const len = value?.length ?? 0;
+ const over = limit ? len > limit : false;
+ return (
+
+ |
+ {tag}
+ |
+
+ {value || —}
+ |
+
+ {limit ? `${len} / ${limit}` : len || ''}
+ |
+
+ );
+};
+
+export const MetaTagsTable = ({
+ data,
+}: {
+ data: OgData;
+}): React.ReactElement => {
+ const url = `https://${data.domain}${data.path || ''}`;
+ return (
+
+ );
+};
diff --git a/packages/storybook/stories/open-graph/useCases.tsx b/packages/storybook/stories/open-graph/useCases.tsx
new file mode 100644
index 00000000000..ee53ae7914e
--- /dev/null
+++ b/packages/storybook/stories/open-graph/useCases.tsx
@@ -0,0 +1,591 @@
+import React from 'react';
+import {
+ cloudinaryCharmEmptySquads,
+ cloudinaryCharmReadLater,
+ cloudinaryCharmNotEnoughTags,
+} from '@dailydotdev/shared/src/lib/image';
+import type { OgData } from './platformCards';
+import { RecommendedOg } from './dailyOgImages';
+
+export const DEFAULT_OG_IMAGE =
+ 'https://media.daily.dev/image/upload/s--VAY5ToZt--/f_auto/v1724209435/public/daily.dev%20-%20open%20graph';
+
+// The CURRENT column uses the REAL images daily.dev serves today (raw, not
+// re-created). Reality check (verified live 2026-06-17): only POSTS get a
+// generated card; profile, source, tag, squad, invite, Plus and the app
+// default all fall back to the SAME generic image. The marketing homepage
+// serves its own og-image.png.
+const LANDING_OG = 'https://daily.dev/og-image.png';
+
+// A real, live daily.dev post (so the current post card is the genuine asset).
+const POST_OG = 'https://og.daily.dev/api/posts/qojM1enSN';
+// The shared variant adds the sharer (real endpoint, ?userid=).
+const SHARED_OG = `${POST_OG}?userid=u_123`;
+const POST_TITLE = 'How to use traces to avoid breaking changes';
+const POST_SOURCE = 'Community Picks';
+const POST_COVER =
+ 'https://media.daily.dev/image/upload/f_auto,q_auto/v1/posts/2a3c2ca8481678b5c9178cd656700187';
+// The post's real source logo (Community Picks) and the real commenter avatar.
+const POST_SOURCE_LOGO =
+ 'https://media.daily.dev/image/upload/t_logo,f_auto/v1655817725/logos/community';
+const COMMENT_AVATAR =
+ 'https://media.daily.dev/image/upload/s--IbwvoTYq--/f_auto/v1772778404/avatars/avatar_giQfoBCN9hYxcvRVo3s95';
+
+// Profile share = the real DevCard "wide" image (ProfileLayout sets og:image to
+// /devcards/v2/{userId}.png?type=wide — a generated DevCard, NOT the generic).
+const PROFILE_OG =
+ 'https://api.daily.dev/devcards/v2/28849d86070e4c099c877ab6837c61f0.png?type=wide';
+// Real assets for the recommended profile: the user's avatar, their rank-based
+// DevCard cover (the bg changes with reputation rank), and real source logos.
+const PROFILE_AVATAR =
+ 'https://media.daily.dev/image/upload/s---xy_OAwk--/f_auto,q_auto/v1703781380/avatars/avatar_28849d86070e4c099c877ab6837c61f0';
+const PROFILE_RANK_COVER =
+ 'https://media.daily.dev/image/upload/s--xDkKz00z--/f_auto/v1707920136/covers/cover_28849d86070e4c099c877ab6837c61f0';
+// The user's real "reads the most" sources, straight from their DevCard.
+const PROFILE_SOURCES = [
+ 'https://media.daily.dev/image/upload/s--fk_6ycEi--/f_auto,q_auto/v1780996001/logos/collections',
+ 'https://media.daily.dev/image/upload/s--stRJbTCn--/f_auto/v1752953684/squads/ad08ba59-6646-487f-a8bd-2147d9e572a6',
+ 'https://media.daily.dev/image/upload/s--V91DY4ls--/f_auto,q_auto/v1772617267/logos/agents_digest',
+ 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/hn',
+];
+// Real WebDev squad share image (its own image is what production serves).
+const WEBDEV_OG =
+ 'https://media.daily.dev/image/upload/s--3B1fh4kU--/f_auto,q_auto/v1/squads/94fc7a56-e6d2-403f-acd6-b988b426574f';
+// Real freeCodeCamp source logo.
+const FREECODECAMP_LOGO =
+ 'https://media.daily.dev/image/upload/t_logo,f_auto/v1628412854/logos/freecodecamp';
+
+export interface UseCase {
+ id: string;
+ /** Human label for the share type. */
+ name: string;
+ /** What object is being shared and from where. */
+ what: string;
+ /** ReferralCampaignKey / route that produces it. */
+ source: string;
+ /** Problems with the current treatment. */
+ issues: string[];
+ /** What we should change. */
+ recommendations: string[];
+ current: OgData;
+ recommended: OgData;
+}
+
+export const USE_CASES: UseCase[] = [
+ {
+ id: 'article',
+ name: 'Article / Post',
+ what: 'A developer shares an aggregated article from the feed or post page.',
+ source: '/posts/[slug] · ReferralCampaignKey.SharePost',
+ issues: [
+ 'og:title always gets a " | daily.dev" suffix appended, eating ~12 chars of the headline and pushing it over the 60-char sweet spot.',
+ 'og:description falls back to a truncated, often HTML-stripped excerpt with no consistent value framing.',
+ 'The generated image leads with the publisher’s cover; daily.dev branding is small and easy to miss.',
+ 'No og:image:alt / twitter:image:alt is set.',
+ ],
+ recommendations: [
+ 'Drop the title suffix for shareable content — site_name already conveys the brand. Reserve the suffix for navigational pages.',
+ 'Lead the image with the headline + author + reading time over a consistent branded frame; treat the cover as a supporting thumbnail.',
+ 'Always emit og:image:alt mirroring the post title.',
+ 'Add twitter:label/data (Author, Reading time) for richer Slack/X-adjacent unfurls.',
+ ],
+ current: {
+ domain: 'app.daily.dev',
+ path: '/posts/how-to-use-traces-to-avoid-breaking-changes',
+ title: 'How to use traces to avoid breaking changes | daily.dev',
+ description:
+ 'How distributed traces help you catch breaking changes before they ship…',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ image: POST_OG,
+ },
+ recommended: {
+ domain: 'app.daily.dev',
+ path: '/posts/how-to-use-traces-to-avoid-breaking-changes',
+ title: 'How to use traces to avoid breaking changes',
+ description:
+ 'How distributed traces help you catch breaking changes before they ship — a practical walkthrough.',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ imageAlt: 'How to use traces to avoid breaking changes',
+ imageNode: (
+
+ ),
+ squareNode: (
+
+ ),
+ },
+ },
+ {
+ id: 'shared-post',
+ name: 'Shared post (with attribution)',
+ what: 'A user shares a post via their personal share link, attributing the share to them.',
+ source:
+ '/posts/[id]/share?userid=… · og.daily.dev/api/posts/{id}?userid={uid}',
+ issues: [
+ 'Attribution ("X shared") is small and inconsistent vs. the standard post image.',
+ 'Same title-suffix and description issues as a normal post.',
+ 'The sharer’s avatar/identity is not reliably surfaced, weakening the social proof that drives the click.',
+ ],
+ recommendations: [
+ 'Make the sharer a first-class element: avatar + "shared by {name}" pill at the top of the image.',
+ 'Keep the rest of the article template identical so the share feels like a native daily.dev object.',
+ ],
+ current: {
+ domain: 'app.daily.dev',
+ path: '/posts/.../share?userid=u_123',
+ title: 'How to use traces to avoid breaking changes | daily.dev',
+ description:
+ 'How distributed traces help you catch breaking changes before they ship…',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ image: SHARED_OG,
+ },
+ recommended: {
+ domain: 'app.daily.dev',
+ path: '/posts/.../share?userid=u_123',
+ title: 'How to use traces to avoid breaking changes',
+ description:
+ 'Ido shared this on daily.dev — how traces catch breaking changes before they ship.',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ imageAlt: 'Ido shared: How to use traces to avoid breaking changes',
+ imageNode: (
+
+ ),
+ },
+ },
+ {
+ id: 'profile',
+ name: 'Developer profile',
+ what: 'Sharing a user profile / DevCard.',
+ source:
+ '/[username] · DevCard v2 wide image · ReferralCampaignKey.ShareProfile',
+ issues: [
+ 'The profile shares the DevCard image (/devcards/v2/{id}.png?type=wide) — a different visual language from post shares, and not 1.91:1, so it gets letterboxed/cropped in unfurls.',
+ 'Bio (og:description) is often empty; falls back to the generic site description.',
+ 'Title carries the " | daily.dev" suffix.',
+ ],
+ recommendations: [
+ 'Use the same template family as posts, in "developer" mode: avatar, name, @handle, headline stat (streak, reputation).',
+ 'Default description to a generated one-liner ("{name} reads about React, Rust & AI on daily.dev") when bio is empty.',
+ ],
+ current: {
+ domain: 'app.daily.dev',
+ path: '/idoshamun',
+ title: 'Ido Shamun (@idoshamun) | daily.dev',
+ description:
+ 'daily.dev is the easiest way to stay updated on the latest programming news…',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ image: PROFILE_OG,
+ },
+ recommended: {
+ domain: 'app.daily.dev',
+ path: '/idoshamun',
+ title: 'Ido Shamun (@idoshamun)',
+ description:
+ 'Ido reads about AI, LLMs & web dev on daily.dev — 20.5k posts read, 1,087-day longest streak.',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ imageAlt: 'Ido Shamun on daily.dev',
+ imageNode: (
+
+ ),
+ },
+ },
+ {
+ id: 'squad',
+ name: 'Squad',
+ what: 'Sharing a Squad’s public page.',
+ source:
+ '/squads/[handle] · getSquadOpenGraph() · ReferralCampaignKey.ShareSource',
+ issues: [
+ 'Uses the squad’s own uploaded banner, whose dimensions/quality vary wildly and may not be 1.91:1.',
+ 'Falls back to the generic brand image when no banner is set — zero context.',
+ 'No member count / activity signal in the preview to drive joins.',
+ ],
+ recommendations: [
+ 'Render a generated, on-brand squad card: squad name + avatar + member count + "active today" signal, with the banner as a backdrop.',
+ 'Never fall back to the bare generic image — always generate a contextual squad card.',
+ ],
+ current: {
+ domain: 'app.daily.dev',
+ path: '/squads/webdev',
+ title: 'WebDev | daily.dev',
+ description:
+ 'The official daily.dev web development community. Led by thought leaders and webdev experts. Join the Squad!',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ image: WEBDEV_OG,
+ },
+ recommended: {
+ domain: 'app.daily.dev',
+ path: '/squads/webdev',
+ title: 'WebDev — a Squad on daily.dev',
+ description:
+ 'The official daily.dev web development community, led by thought leaders and webdev experts.',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ imageAlt: 'WebDev squad on daily.dev',
+ imageNode: (
+
+ ),
+ },
+ },
+ {
+ id: 'squad-invite',
+ name: 'Squad invite link',
+ what: 'A member invites someone to a private/public Squad.',
+ source: '/squads/[handle]/[token]',
+ issues: [
+ 'Title is good ("{inviter} invited you to {squad}") but the image is just the squad banner — the invite framing lives only in text the platform may truncate.',
+ 'No inviter identity in the image; the personal, social hook is lost.',
+ ],
+ recommendations: [
+ 'Bake the invite framing into the image: inviter avatar + "invited you to join {squad}" + member count + CTA chip.',
+ 'Keep the strong title but ensure the image stands on its own when the title is clipped.',
+ ],
+ current: {
+ domain: 'app.daily.dev',
+ path: '/squads/webdev/abc123token',
+ title: 'Ido invited you to WebDev',
+ description:
+ 'The official daily.dev web development community. Led by thought leaders and webdev experts. Join the Squad!',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ image: WEBDEV_OG,
+ },
+ recommended: {
+ domain: 'app.daily.dev',
+ path: '/squads/webdev/abc123token',
+ title: 'Ido invited you to join WebDev on daily.dev',
+ description:
+ 'Join the official daily.dev web development community. It’s free.',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ imageAlt: 'Ido invited you to join WebDev',
+ imageNode: (
+
+ ),
+ },
+ },
+ {
+ id: 'source',
+ name: 'Source / Publication',
+ what: 'Sharing a publication/source page.',
+ source: '/sources/[source]',
+ issues: [
+ 'Uses the bare generic brand image (defaultOpenGraph) — no indication of which source it is.',
+ 'Title + suffix only; description is the source description if present.',
+ ],
+ recommendations: [
+ 'Generate a source card with the source logo, name and "Followed by N developers on daily.dev".',
+ ],
+ current: {
+ domain: 'app.daily.dev',
+ path: '/sources/freecodecamp',
+ title: 'freeCodeCamp | daily.dev',
+ description: 'The latest from freeCodeCamp on daily.dev.',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ image: DEFAULT_OG_IMAGE,
+ },
+ recommended: {
+ domain: 'app.daily.dev',
+ path: '/sources/freecodecamp',
+ title: 'freeCodeCamp on daily.dev',
+ description:
+ 'Followed by developers across daily.dev. Get every new post in your feed.',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ imageAlt: 'freeCodeCamp on daily.dev',
+ imageNode: (
+
+ ),
+ },
+ },
+ {
+ id: 'tag',
+ name: 'Tag / Topic feed',
+ what: 'Sharing a tag feed (e.g. /tags/react).',
+ source: 'ReferralCampaignKey.ShareTag',
+ issues: ['Generic brand image; the topic name appears only in text.'],
+ recommendations: [
+ 'Generate a topic card: "#react" + a one-line topic description + a few sample source logos.',
+ ],
+ current: {
+ domain: 'app.daily.dev',
+ path: '/tags/react',
+ title: 'react | daily.dev',
+ description:
+ 'Explore the React JavaScript library for building user interfaces.',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ image: DEFAULT_OG_IMAGE,
+ },
+ recommended: {
+ domain: 'app.daily.dev',
+ path: '/tags/react',
+ title: '#react on daily.dev',
+ description:
+ 'Explore the React JavaScript library for building user interfaces.',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ imageAlt: 'The #react topic on daily.dev',
+ imageNode: (
+
+ ),
+ },
+ },
+ {
+ id: 'comment',
+ name: 'Comment / Discussion',
+ what: 'Sharing a specific comment on a post.',
+ source: 'ReferralCampaignKey.ShareComment',
+ issues: [
+ 'Reuses the post image; the comment that the user actually wanted to highlight is invisible in the preview.',
+ ],
+ recommendations: [
+ 'Generate a discussion card: comment excerpt as the hero, commenter avatar/name, post title as context.',
+ ],
+ current: {
+ domain: 'app.daily.dev',
+ path: '/posts/qojM1enSN#c_456',
+ title: 'How to use traces to avoid breaking changes | daily.dev',
+ description:
+ 'Reuses the post image — the highlighted comment is invisible in the preview.',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ image: POST_OG,
+ },
+ recommended: {
+ domain: 'app.daily.dev',
+ path: '/posts/qojM1enSN#c_456',
+ title: '“Great article! Thanks for sharing 🙌” — sirajju on daily.dev',
+ description:
+ 'sirajju commented on “How to use traces to avoid breaking changes”. Join the discussion.',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ imageAlt: 'Comment by sirajju on a daily.dev discussion',
+ imageNode: (
+
+ ),
+ },
+ },
+ {
+ id: 'invite',
+ name: 'Invite friends / Referral',
+ what: 'Personal referral link (the generic /?cid root link from Invite friends).',
+ source: '/?cid=… · ReferralCampaignKey.Generic',
+ issues: [
+ 'The root referral link (app.daily.dev/?cid=…&userid=…) falls back to the generic brand image — no referrer, no incentive, no personalization.',
+ 'Highest-intent growth surface, weakest preview. (A separate og.daily.dev/api/refs card exists for /join links, but it’s off the unified template and currently returns empty for many users.)',
+ ],
+ recommendations: [
+ 'Generate a referral card with the referrer’s avatar + "{name} invited you to daily.dev" + a clear CTA, on the unified template.',
+ ],
+ current: {
+ domain: 'app.daily.dev',
+ path: '/?cid=generic&userid=u_123',
+ title: 'daily.dev | Where developers grow together',
+ description:
+ 'daily.dev is the easiest way to stay updated on the latest programming news…',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ image: DEFAULT_OG_IMAGE,
+ },
+ recommended: {
+ domain: 'app.daily.dev',
+ path: '/?cid=generic&userid=u_123',
+ title: 'Ido invited you to daily.dev',
+ description:
+ 'The professional network where developers grow together. Free forever.',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ imageAlt: 'Ido invited you to daily.dev',
+ imageNode: (
+
+ ),
+ },
+ },
+ {
+ id: 'plus',
+ name: 'Plus subscription',
+ what: 'Sharing / gifting daily.dev Plus.',
+ source: '/plus',
+ issues: [
+ 'Generic brand image; nothing communicates the Plus value or the gift.',
+ ],
+ recommendations: [
+ 'Generate a Plus card with the Plus mark, headline benefit and gift framing when applicable.',
+ ],
+ current: {
+ domain: 'app.daily.dev',
+ path: '/plus',
+ title: 'Unlock Premium Developer Features with Plus | daily.dev',
+ description:
+ 'daily.dev is the easiest way to stay updated on the latest programming news…',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ image: DEFAULT_OG_IMAGE,
+ },
+ recommended: {
+ domain: 'app.daily.dev',
+ path: '/plus',
+ title: 'daily.dev Plus — your feed, supercharged',
+ description:
+ 'AI summaries, advanced filters, and an ad-free feed. Try Plus free for 14 days.',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ imageAlt: 'daily.dev Plus',
+ imageNode: (
+
+ ),
+ },
+ },
+ {
+ id: 'home',
+ name: 'Homepage / default',
+ what: 'The root URL and any page without a specific override.',
+ source: 'next-seo.ts defaultOpenGraph',
+ issues: [
+ 'Single static image; fine as a true fallback but currently does double duty for many specific share types it should not.',
+ ],
+ recommendations: [
+ 'Keep as the genuine last-resort fallback only. Every specific surface above should override it.',
+ ],
+ current: {
+ domain: 'app.daily.dev',
+ path: '/',
+ title: 'daily.dev | Where developers grow together',
+ description:
+ 'daily.dev is the easiest way to stay updated on the latest programming news. Get the best content from the top tech publications on any topic you want.',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ image: DEFAULT_OG_IMAGE,
+ },
+ recommended: {
+ domain: 'app.daily.dev',
+ path: '/',
+ title: 'daily.dev | Where developers grow together',
+ description:
+ 'The professional network for developers. One feed for the best engineering content, every day.',
+ cardType: 'summary_large_image',
+ siteName: 'daily.dev',
+ twitterSite: '@dailydotdev',
+ imageAlt: 'daily.dev — where developers grow together',
+ image: LANDING_OG,
+ },
+ },
+];
diff --git a/packages/webapp/components/image-generator/ShareCard.tsx b/packages/webapp/components/image-generator/ShareCard.tsx
new file mode 100644
index 00000000000..0f9173fa5e7
--- /dev/null
+++ b/packages/webapp/components/image-generator/ShareCard.tsx
@@ -0,0 +1,719 @@
+import type { ReactElement, ReactNode } from 'react';
+import React from 'react';
+import LogoIcon from '@dailydotdev/shared/src/svg/LogoIcon';
+import LogoText from '@dailydotdev/shared/src/svg/LogoText';
+import { UpvoteIcon } from '@dailydotdev/shared/src/components/icons/Upvote';
+import { DiscussIcon } from '@dailydotdev/shared/src/components/icons/Discuss';
+import { SquadIcon } from '@dailydotdev/shared/src/components/icons/Squad';
+import { DocsIcon } from '@dailydotdev/shared/src/components/icons/Docs';
+import { UserIcon } from '@dailydotdev/shared/src/components/icons/User';
+import { IconSize } from '@dailydotdev/shared/src/components/Icon';
+import {
+ cloudinaryCharmEmptySquads,
+ cloudinaryCharmNotEnoughTags,
+} from '@dailydotdev/shared/src/lib/image';
+
+// Compact count: 1234 -> "1.2K", 1200000 -> "1.2M".
+const fmt = (n?: number): string => {
+ if (!n) {
+ return '0';
+ }
+ if (n < 1000) {
+ return `${n}`;
+ }
+ if (n < 1_000_000) {
+ return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0).replace(/\.0$/, '')}K`;
+ }
+ return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
+};
+
+/**
+ * "Layout A" share-card design system, rendered as real DOM (screenshotted at
+ * 1200×630 by the daily-api `/og` route via the scraper). Because this renders
+ * in a real browser — not Satori — `line-clamp`, `backdrop-blur`, the real
+ * fonts and the daily.dev design tokens all work natively; no truncation or
+ * font-metric workarounds needed.
+ *
+ * The page wraps the chosen card in `#screenshot_wrapper` sized to exactly
+ * 1200×630; each card fills that frame.
+ */
+
+export interface Identity {
+ name: string;
+ image?: string;
+ meta?: string; // trailing context, e.g. "6m read"
+ label?: string; // verb after the name, e.g. "invited you" / "shared"
+ // Show an avatar slot: an image, or an initial fallback when `fallback` is
+ // set. Label-only eyebrows ("Squad", "daily.dev", "Topic") set neither, so
+ // no avatar/logo renders before the text.
+ fallback?: boolean;
+}
+
+const Wordmark = (): ReactElement => (
+
+
+
+
+);
+
+const Backdrop = ({ cover }: { cover?: string }): ReactElement => (
+
+ {cover ? (
+ <>
+

+
+ >
+ ) : (
+
+ )}
+
+);
+
+const IdentityAvatar = ({
+ identity,
+ shape,
+}: {
+ identity: Identity;
+ shape: string;
+}): ReactElement | null => {
+ if (identity.image) {
+ return (
+
+ );
+ }
+ // Person with no avatar: the initial on the brand gradient. Label-only
+ // eyebrows (Squad / daily.dev / Topic) opt out entirely.
+ if (identity.fallback) {
+ return (
+
+ {identity.name?.charAt(0)?.toUpperCase() || ''}
+
+ );
+ }
+ return null;
+};
+
+const IdentityRow = ({ identity }: { identity: Identity }): ReactElement => {
+ // Compact eyebrow matching the storybook REyebrow (~44px avatar, 28px text).
+ const avatarShape =
+ 'h-11 w-11 shrink-0 rounded-full border border-[rgba(255,255,255,0.2)]';
+ return (
+
+
+
+ {identity.name}
+ {!!identity.label && {identity.label}}
+ {!!identity.meta && · {identity.meta}}
+
+
+ );
+};
+
+const Title = ({
+ children,
+ className = 'text-[60px]',
+ lines = 'line-clamp-3',
+}: {
+ children: ReactNode;
+ className?: string;
+ lines?: string;
+}): ReactElement => (
+
+ {children}
+
+);
+
+const Subtitle = ({ children }: { children: ReactNode }): ReactElement => (
+
+ {children}
+
+);
+
+const GlassBar = ({ children }: { children: ReactNode }): ReactElement => (
+
+ {children}
+
+);
+
+const EngagementBar = ({
+ upvotes,
+ comments,
+}: {
+ upvotes: number;
+ comments: number;
+}): ReactElement => (
+
+
+
+ {upvotes}
+
+
+
+
+ {comments}
+
+
+);
+
+const MetaPill = ({ text }: { text: string }): ReactElement => (
+
+ {text}
+
+);
+
+interface Stat {
+ id: string;
+ icon: ReactNode;
+ value: string;
+}
+
+// Glass bar of icon+number stats (squad members / posts / upvotes).
+const StatBar = ({ stats }: { stats: Stat[] }): ReactElement => (
+
+
+ {stats.map((s, i) => (
+
+ {i > 0 && (
+
+ )}
+
+ {s.icon}
+ {s.value}
+
+
+ ))}
+
+
+);
+
+// Community proof — a pile of member avatars + the count (squads / sources).
+const CommunityBar = ({
+ faces,
+ count,
+}: {
+ faces: string[];
+ count: string;
+}): ReactElement => (
+
+
+
+ {faces.map((src, i) => (
+
0 ? '-ml-3' : ''
+ }`}
+ />
+ ))}
+
+
+ {count}
+
+
+
+);
+
+// Prominent member count for invite cards: a user icon + the number in the
+// brand cabbage colour + a word ("developers"), e.g. "26,606 developers".
+const ProminentCount = ({
+ count,
+ word,
+}: {
+ count: string;
+ word?: string;
+}): ReactElement => (
+
+
+
+
+ {count}
+
+ {word ? ` ${word}` : ''}
+
+
+);
+
+const PrimaryButton = ({ children }: { children: ReactNode }): ReactElement => (
+
+ {children}
+
+);
+
+const Tile = ({ children }: { children: ReactNode }): ReactElement => (
+
+ {children}
+
+);
+
+const ImageArt = ({
+ src,
+ circle,
+}: {
+ src: string;
+ circle?: boolean;
+}): ReactElement => (
+
+);
+
+// Charm mascot (the dog) anchored into the bottom-right corner — overflows the
+// art slot a touch; the frame's overflow-hidden clips it at the canvas edge.
+const Mascot = ({ src }: { src: string }): ReactElement => (
+
+);
+
+interface OgFrameProps {
+ backdrop?: string;
+ identity: Identity;
+ children: ReactNode; // content column (title + subtitle, or richer)
+ meta?: ReactNode; // bottom-left slot
+ art?: ReactNode; // bottom-right slot
+}
+
+const OgFrame = ({
+ backdrop,
+ identity,
+ children,
+ meta,
+ art,
+}: OgFrameProps): ReactElement => (
+
+
+
+ {/* top bar */}
+
+
+
+
+
+ {/* content column, vertically centred between top bar and meta/bottom */}
+
+ {children}
+
+
+ {/* meta slot */}
+ {!!meta &&
{meta}
}
+
+ {/* art slot */}
+ {!!art && (
+
+ {art}
+
+ )}
+
+);
+
+// ---- per-type cards -------------------------------------------------------
+
+export interface PostCardData {
+ title?: string;
+ summary?: string;
+ image?: string;
+ numUpvotes?: number;
+ numComments?: number;
+ readTime?: number;
+ source?: { name?: string; image?: string; type?: string };
+ author?: { name?: string; username?: string; image?: string };
+ sharer?: { name?: string; image?: string };
+}
+
+export const PostShareCard = ({
+ data,
+}: {
+ data: PostCardData;
+}): ReactElement => {
+ const isUserSource = data.source?.type === 'user';
+ const identity: Identity = data.sharer
+ ? {
+ name: data.sharer.name ?? '',
+ image: data.sharer.image,
+ fallback: true,
+ label: 'shared this',
+ }
+ : {
+ name: (isUserSource ? data.author?.name : data.source?.name) ?? '',
+ image: isUserSource ? data.author?.image : data.source?.image,
+ fallback: true,
+ meta: data.readTime ? `${data.readTime}m read` : undefined,
+ };
+ // On a shared card the source name is the subtitle (like the storybook mock);
+ // on a regular article it's the post summary.
+ const subtitle = data.sharer ? data.source?.name : data.summary;
+ return (
+ : undefined}
+ meta={
+
+ }
+ >
+ {data.title}
+ {!!subtitle && {subtitle}}
+
+ );
+};
+
+export interface CommentCardData {
+ content: string;
+ numUpvotes?: number;
+ replies?: number; // replies to THIS comment (children), not the post total
+ author: { name: string; image?: string };
+ postTitle?: string;
+}
+
+export const CommentShareCard = ({
+ data,
+}: {
+ data: CommentCardData;
+}): ReactElement => (
+
+ }
+ >
+ {`“${data.content}”`}
+ {!!data.postTitle && {`on “${data.postTitle}”`}}
+
+);
+
+export interface SourceCardData {
+ name: string;
+ image?: string;
+ description?: string;
+ followers?: number;
+ faces?: string[]; // member/follower avatars for the community pile
+}
+
+export const SourceShareCard = ({
+ data,
+}: {
+ data: SourceCardData;
+}): ReactElement => {
+ // Community proof (the storybook Community pattern): member faces + count.
+ const count = data.followers
+ ? `Followed by ${fmt(data.followers)} developers`
+ : undefined;
+ let meta: ReactNode;
+ if (data.faces?.length) {
+ meta = ;
+ } else if (count) {
+ meta = ;
+ }
+ return (
+ : undefined}
+ meta={meta}
+ >
+
+ {data.name}
+
+ {!!data.description && {data.description}}
+
+ );
+};
+
+export interface SquadCardData {
+ name: string;
+ image?: string;
+ description?: string;
+ members?: number;
+ posts?: number;
+ upvotes?: number;
+ sharer?: { name?: string; image?: string }; // optional referrer (?userid)
+}
+
+export const SquadShareCard = ({
+ data,
+}: {
+ data: SquadCardData;
+}): ReactElement => {
+ // Matches the storybook squad card: a Members / Posts / Upvotes stat bar.
+ const stats: Stat[] = [];
+ if (data.members) {
+ stats.push({
+ id: 'members',
+ icon: ,
+ value: fmt(data.members),
+ });
+ }
+ if (data.posts) {
+ stats.push({
+ id: 'posts',
+ icon: ,
+ value: fmt(data.posts),
+ });
+ }
+ if (data.upvotes) {
+ stats.push({
+ id: 'upvotes',
+ icon: ,
+ value: fmt(data.upvotes),
+ });
+ }
+ const art = data.image ? (
+
+ ) : (
+
+
+ {data.name?.[0]}
+
+
+ );
+
+ // Shared via a referral (?userid) → the invite treatment (storybook):
+ // "{inviter} invited you", "Join {squad}", a prominent member count, and a
+ // "Tap to join" CTA. Otherwise the plain squad card with its stat bar.
+ if (data.sharer) {
+ return (
+ Tap to join}
+ >
+
+ {`Join ${data.name}`}
+
+ {!!data.members && (
+
+ )}
+
+ );
+ }
+
+ return (
+ 0 ? : undefined}
+ >
+
+ {data.name}
+
+ {!!data.description && {data.description}}
+
+ );
+};
+
+// ---- profile card (distinct from the DevCard) -----------------------------
+export interface ProfileCardData {
+ name: string;
+ handle?: string;
+ image?: string;
+ bio?: string;
+ reputation?: number;
+ streak?: number;
+ reads?: number;
+ tags?: string[];
+ sources?: string[]; // most-read source logos
+}
+
+const ProfileStats = ({
+ stats,
+}: {
+ stats: Array<{ value: string; label: string }>;
+}): ReactElement => (
+
+
+ {stats.map((s, i) => (
+
+ {i > 0 && (
+
+ )}
+
+
+ {s.value}
+
+
+ {s.label}
+
+
+
+ ))}
+
+
+);
+
+const SourceLogos = ({ logos }: { logos: string[] }): ReactElement => (
+
+
+ Reads from
+
+
+ {logos.map((src) => (
+
+
+
+ ))}
+
+
+);
+
+const ProfileTagChips = ({ tags }: { tags: string[] }): ReactElement => (
+
+ {tags.map((t) => (
+
+ #{t}
+
+ ))}
+
+);
+
+export const ProfileShareCard = ({
+ data,
+}: {
+ data: ProfileCardData;
+}): ReactElement => {
+ const stats: Array<{ value: string; label: string }> = [];
+ if (data.reputation) {
+ stats.push({ value: fmt(data.reputation), label: 'Reputation' });
+ }
+ if (data.streak) {
+ stats.push({ value: fmt(data.streak), label: 'Longest streak' });
+ }
+ if (data.reads) {
+ stats.push({ value: fmt(data.reads), label: 'Posts read' });
+ }
+ return (
+ : undefined}
+ >
+
+
+ {data.name}
+
+ {!!data.bio &&
{data.bio}}
+ {stats.length > 0 && (
+
+ )}
+ {!!data.sources?.length && (
+
+
+
+ )}
+ {!!data.tags?.length && (
+
+ )}
+
+
+ );
+};
+
+export const TagShareCard = ({ tag }: { tag: string }): ReactElement => (
+ }
+ meta={{`Explore #${tag}`}}
+ >
+ {`#${tag}`}
+
+ Follow this topic to fill your feed with the best posts about it.
+
+
+);
+
+export const InviteShareCard = ({
+ name,
+ image,
+}: {
+ name: string;
+ image?: string;
+}): ReactElement => (
+ }
+ meta={Tap to join}
+ >
+ Join me on daily.dev
+ The one feed developers actually read
+
+);
+
+export const PlusShareCard = (): ReactElement => (
+
+
+ +
+
+
+ }
+ meta={Upgrade to Plus}
+ >
+ Supercharge your daily.dev
+
+ Plus unlocks advanced AI, custom feeds, and a clutter-free, ad-free
+ experience.
+
+
+);
diff --git a/packages/webapp/next-seo.ts b/packages/webapp/next-seo.ts
index a298db89991..c6cca142f4f 100644
--- a/packages/webapp/next-seo.ts
+++ b/packages/webapp/next-seo.ts
@@ -40,6 +40,34 @@ export const defaultSeo: Partial = {
],
};
+// Contextual share images rendered by the webapp and screenshotted by
+// daily-api at /og//.png (mirrors the devcard v2 image pipeline).
+export type ShareImageType =
+ | 'posts'
+ | 'comments'
+ | 'sources'
+ | 'squads'
+ | 'tags'
+ | 'invite'
+ | 'plus';
+
+export const getShareImageUrl = (
+ type: ShareImageType,
+ id: string,
+ params?: Record,
+): string => {
+ const url = new URL(
+ `/og/${type}/${encodeURIComponent(id)}.png`,
+ process.env.NEXT_PUBLIC_API_URL,
+ );
+ Object.entries(params ?? {}).forEach(([key, value]) => {
+ if (value) {
+ url.searchParams.set(key, value);
+ }
+ });
+ return url.toString();
+};
+
export const getSquadOpenGraph = ({
squad,
}: {
diff --git a/packages/webapp/pages/image-generator/share/[type]/[id].tsx b/packages/webapp/pages/image-generator/share/[type]/[id].tsx
new file mode 100644
index 00000000000..5437562fd6d
--- /dev/null
+++ b/packages/webapp/pages/image-generator/share/[type]/[id].tsx
@@ -0,0 +1,376 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import type {
+ GetStaticPathsResult,
+ GetStaticPropsContext,
+ GetStaticPropsResult,
+} from 'next';
+import { useRouter } from 'next/router';
+import { useQuery } from '@tanstack/react-query';
+import { useDevCard } from '@dailydotdev/shared/src/hooks/profile/useDevCard';
+import { gqlClient } from '@dailydotdev/shared/src/graphql/common';
+import {
+ CommentShareCard,
+ InviteShareCard,
+ PlusShareCard,
+ PostShareCard,
+ ProfileShareCard,
+ SourceShareCard,
+ SquadShareCard,
+ TagShareCard,
+} from '../../../../components/image-generator/ShareCard';
+
+// These pages render anonymously and are screenshotted, so they must query
+// ONLY public fields. The app's data hooks (usePostById / SQUAD_QUERY / …)
+// pull user-scoped fields (bookmark/user state, membership, content
+// preferences) which require auth — an anonymous request to them returns
+// "Access denied". So we use minimal, public-only queries (mirroring the old
+// og.daily.dev Satori service) and fire them immediately — no boot/auth needed.
+
+const OG_POST_QUERY = `
+ query Post($id: ID!) {
+ post(id: $id) {
+ title
+ summary
+ image
+ readTime
+ numUpvotes
+ numComments
+ source { name image handle type }
+ author { name username image }
+ sharedPost { title summary image }
+ }
+ }
+`;
+
+const OG_COMMENT_QUERY = `
+ query Comment($id: ID!) {
+ comment(id: $id) {
+ content
+ numUpvotes
+ author { name image }
+ post { title }
+ children { edges { node { id } } }
+ }
+ }
+`;
+
+const OG_SOURCE_QUERY = `
+ query Source($id: ID!) {
+ source(id: $id) {
+ id
+ name
+ image
+ description
+ membersCount
+ }
+ }
+`;
+
+const OG_SQUAD_QUERY = `
+ query Source($id: ID!) {
+ source(id: $id) {
+ id
+ name
+ image
+ description
+ membersCount
+ flags { totalPosts totalUpvotes }
+ }
+ }
+`;
+
+// Minimal public user query — the exact shape the old referral og route used
+// anonymously (avoids the auth-gated fields in the full devcard query).
+const OG_USER_QUERY = `
+ query User($id: ID!) {
+ user(id: $id) {
+ name
+ image
+ }
+ }
+`;
+
+// Decorative community face pile. Per the designer, these are a fixed pool of
+// AI-generated avatars (not real followers — there's no query for a source's
+// follower avatars). We deterministically pick 3 from the pool seeded by the
+// entity id, so a given source always shows the same 3 (stable across the
+// preview and the cached screenshot) while different sources rotate.
+// TODO: replace with the final 20 AI-generated avatars hosted on media.daily.dev.
+const COMMUNITY_FACES = [
+ 'https://media.daily.dev/image/upload/s--FcI3RdS1--/f_auto/v1745335145/avatars/avatar_R9RafYjp15h3mJ9XdIkhy',
+ 'https://media.daily.dev/image/upload/s--AVEMGQgE--/f_auto/v1744349812/avatars/avatar_RVvUzGofSIHGyTxdjqDN1',
+ 'https://media.daily.dev/image/upload/s--CwdXky60--/f_auto/v1733031652/avatars/avatar_rmFJzNXUNPh163VaQkmF0',
+];
+
+const pickFaces = (seed: string, count = 3): string[] => {
+ if (COMMUNITY_FACES.length === 0) {
+ return [];
+ }
+ // Bounded string hash (kept under a prime to avoid float overflow without
+ // bitwise ops) → a stable per-seed offset into the pool.
+ let hash = 0;
+ for (let i = 0; i < seed.length; i += 1) {
+ hash = (hash * 31 + seed.charCodeAt(i)) % 2147483647;
+ }
+ const start = hash % COMMUNITY_FACES.length;
+ return Array.from(
+ { length: Math.min(count, COMMUNITY_FACES.length) },
+ (_, i) => COMMUNITY_FACES[(start + i) % COMMUNITY_FACES.length],
+ );
+};
+
+interface OgUser {
+ name?: string;
+ image?: string;
+}
+
+interface OgPost {
+ title?: string;
+ summary?: string;
+ image?: string;
+ readTime?: number;
+ numUpvotes?: number;
+ numComments?: number;
+ source?: { name?: string; image?: string; handle?: string; type?: string };
+ author?: { name?: string; username?: string; image?: string };
+ sharedPost?: { title?: string; summary?: string; image?: string };
+}
+
+interface OgComment {
+ content: string;
+ numUpvotes?: number;
+ author?: { name?: string; image?: string };
+ post?: { title?: string };
+ children?: { edges?: Array<{ node?: { id?: string } }> };
+}
+
+interface OgSource {
+ id?: string;
+ name: string;
+ image?: string;
+ description?: string;
+ membersCount?: number;
+ flags?: { totalPosts?: number; totalUpvotes?: number };
+}
+
+const PostLoader = ({ id }: { id: string }): ReactElement | null => {
+ const { query } = useRouter();
+ const userId = query?.userid as string;
+ const { data } = useQuery({
+ queryKey: ['og-post', id],
+ queryFn: () => gqlClient.request<{ post: OgPost }>(OG_POST_QUERY, { id }),
+ });
+ const { data: sharerData } = useQuery({
+ queryKey: ['og-user', userId],
+ queryFn: () =>
+ gqlClient.request<{ user: OgUser }>(OG_USER_QUERY, { id: userId }),
+ enabled: !!userId,
+ });
+ const post = data?.post;
+ if (!post) {
+ return null;
+ }
+ // Skip the generic placeholder cover, falling back to a shared post's image.
+ const cover =
+ post.image && !post.image.includes('public/Placeholder')
+ ? post.image
+ : post.sharedPost?.image;
+ const sharer = sharerData?.user
+ ? { name: sharerData.user.name, image: sharerData.user.image }
+ : undefined;
+ return (
+
+ );
+};
+
+const CommentLoader = ({ id }: { id: string }): ReactElement | null => {
+ const { data } = useQuery({
+ queryKey: ['og-comment', id],
+ queryFn: () =>
+ gqlClient.request<{ comment: OgComment }>(OG_COMMENT_QUERY, { id }),
+ });
+ const comment = data?.comment;
+ if (!comment) {
+ return null;
+ }
+ return (
+
+ );
+};
+
+const SourceLoader = ({ id }: { id: string }): ReactElement | null => {
+ const { data } = useQuery({
+ queryKey: ['og-source', id],
+ queryFn: () =>
+ gqlClient.request<{ source: OgSource }>(OG_SOURCE_QUERY, { id }),
+ });
+ const source = data?.source;
+ if (!source) {
+ return null;
+ }
+ return (
+
+ );
+};
+
+const SquadLoader = ({ id }: { id: string }): ReactElement | null => {
+ const { query } = useRouter();
+ const userId = query?.userid as string;
+ const { data } = useQuery({
+ queryKey: ['og-squad', id],
+ queryFn: () =>
+ gqlClient.request<{ source: OgSource }>(OG_SQUAD_QUERY, { id }),
+ });
+ const { data: sharerData } = useQuery({
+ queryKey: ['og-user', userId],
+ queryFn: () =>
+ gqlClient.request<{ user: OgUser }>(OG_USER_QUERY, { id: userId }),
+ enabled: !!userId,
+ });
+ const squad = data?.source;
+ if (!squad) {
+ return null;
+ }
+ return (
+
+ );
+};
+
+const InviteLoader = ({ id }: { id: string }): ReactElement | null => {
+ const { data } = useQuery({
+ queryKey: ['og-user', id],
+ queryFn: () => gqlClient.request<{ user: OgUser }>(OG_USER_QUERY, { id }),
+ });
+ const user = data?.user;
+ if (!user) {
+ return null;
+ }
+ return ;
+};
+
+// Distinct profile card (Layout A) — reuses the public DevCard query for data
+// (reputation, streak, posts read, most-read tags & sources) but renders the
+// share-card layout, not the DevCard. Both can be tested side by side.
+const ProfileLoader = ({ id }: { id: string }): ReactElement | null => {
+ const { devcard } = useDevCard(id);
+ const user = devcard?.user;
+ if (!user) {
+ return null;
+ }
+ return (
+ source.image)
+ .filter((image): image is string => !!image)
+ .slice(0, 4),
+ }}
+ />
+ );
+};
+
+interface ShareImagePageProps {
+ type: string;
+ id: string;
+}
+
+const ShareImagePage = ({ type, id }: ShareImagePageProps): ReactElement => {
+ const card = ((): ReactElement | null => {
+ switch (type) {
+ case 'posts':
+ return ;
+ case 'comments':
+ return ;
+ case 'sources':
+ return ;
+ case 'squads':
+ return ;
+ case 'profile':
+ return ;
+ case 'tags':
+ return ;
+ case 'invite':
+ return ;
+ case 'plus':
+ return ;
+ default:
+ return null;
+ }
+ })();
+
+ return (
+
+ {card}
+
+ );
+};
+
+export function getStaticPaths(): GetStaticPathsResult {
+ return { paths: [], fallback: 'blocking' };
+}
+
+export function getStaticProps({
+ params,
+}: GetStaticPropsContext): GetStaticPropsResult {
+ const type = params?.type as string;
+ const id = params?.id as string;
+ if (!type || !id) {
+ return { notFound: true, revalidate: false };
+ }
+ return { props: { type, id }, revalidate: 60 };
+}
+
+export default ShareImagePage;
diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx
index c326b8b2e2f..8cdf371d9b9 100644
--- a/packages/webapp/pages/posts/[id]/index.tsx
+++ b/packages/webapp/pages/posts/[id]/index.tsx
@@ -51,6 +51,7 @@ import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditio
import { isPostRedesignEligible } from '@dailydotdev/shared/src/hooks/post/usePostRedesign';
import { featurePostRedesign } from '@dailydotdev/shared/src/lib/featureManagement';
import { PostFocusCard } from '@dailydotdev/shared/src/components/post/focus/PostFocusCard';
+import { getShareImageUrl } from '../../../next-seo';
import { getPageSeoTitles } from '../../../components/layouts/utils';
import { getLayout } from '../../../components/layouts/MainLayout';
import FooterNavBarLayout from '../../../components/layouts/FooterNavBarLayout';
@@ -362,7 +363,7 @@ export async function getStaticProps({
...pageSeoTitles.openGraph,
images: [
{
- url: `https://og.daily.dev/api/posts/${post?.id}`,
+ url: getShareImageUrl('posts', post?.id ?? ''),
width: 1200,
height: 630,
alt: post?.title || 'Post cover image',
diff --git a/packages/webapp/pages/sources/[source].tsx b/packages/webapp/pages/sources/[source].tsx
index 74a0f3958cb..36917bfea32 100644
--- a/packages/webapp/pages/sources/[source].tsx
+++ b/packages/webapp/pages/sources/[source].tsx
@@ -69,7 +69,7 @@ import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader
import { useLayoutVariant } from '@dailydotdev/shared/src/hooks/layout/useLayoutVariant';
import { ArchiveScopeType } from '@dailydotdev/shared/src/graphql/archive';
import Custom404 from '../404';
-import { defaultOpenGraph, defaultSeo } from '../../next-seo';
+import { defaultOpenGraph, defaultSeo, getShareImageUrl } from '../../next-seo';
import { mainFeedLayoutProps } from '../../components/layouts/MainFeedPage';
import { getLayout } from '../../components/layouts/FeedLayout';
import { getPageSeoTitles } from '../../components/layouts/utils';
@@ -463,6 +463,13 @@ export async function getStaticProps({
openGraph: {
...defaultOpenGraph,
...seoTitles.openGraph,
+ images: [
+ {
+ url: getShareImageUrl('sources', source.id ?? ''),
+ width: 1200,
+ height: 630,
+ },
+ ],
},
description: source?.description || defaultSeo.description,
};
diff --git a/packages/webapp/pages/tags/[tag].tsx b/packages/webapp/pages/tags/[tag].tsx
index 3ce293f80f0..df40ddd6c39 100644
--- a/packages/webapp/pages/tags/[tag].tsx
+++ b/packages/webapp/pages/tags/[tag].tsx
@@ -24,7 +24,7 @@ import { getPageSeoTitles } from '../../components/layouts/utils';
import { getLayout } from '../../components/layouts/FeedLayout';
import { mainFeedLayoutProps } from '../../components/layouts/MainFeedPage';
import type { DynamicSeoProps } from '../../components/common';
-import { defaultOpenGraph, defaultSeo } from '../../next-seo';
+import { defaultOpenGraph, defaultSeo, getShareImageUrl } from '../../next-seo';
import { getAppOrigin } from '../../lib/seo';
const appOrigin = getAppOrigin();
@@ -144,6 +144,7 @@ interface TagPageParams extends ParsedUrlQuery {
const getSeoData = (
title: string,
description = `Find all the recent posts, videos, updates and discussions about ${title}`,
+ tagSlug = title,
): NextSeoProps => {
const seoTitles = getPageSeoTitles(`${title} posts`);
@@ -153,6 +154,13 @@ const getSeoData = (
openGraph: {
...defaultOpenGraph,
...seoTitles.openGraph,
+ images: [
+ {
+ url: getShareImageUrl('tags', tagSlug),
+ width: 1200,
+ height: 630,
+ },
+ ],
},
description,
};
@@ -227,6 +235,7 @@ export async function getStaticProps({
const seo = getSeoData(
initialData.flags?.title || tag,
initialData.flags?.description,
+ tag,
);
return {