A brief you can reuse for any project’s link previews: the og:image, title and description shown when a URL is pasted into Slack, Zoom, X, LinkedIn, WhatsApp or iMessage. It’s distilled from the moodboard and the per-page study of 26 sites. Flowershow’s phase 2 spec is a worked example, and the build advice in section 8 comes from it.
Pixel numbers (margins, type sizes, ratios, thresholds) are starting defaults, not measured. Tune them on your own pages.
Every page gets its own preview, and it never breaks. A preview should tell someone which page this is before they click. One logo on every page fails the first half; an empty or broken image fails the second.
| Site | Strategy | Examples |
|---|---|---|
| Marketing site, a handful of pages | Hand-designed image per page | (most landing pages in the moodboard) |
| Blog or magazine with an art habit | Template plus per-post art: fixed frame, new picture | Cloudflare, Stripe, Supabase, Vercel (recent posts; older ones fall back to the logo card) |
| Docs, wiki, knowledge base (mostly text) | Generated text card: title, description and section from the page | Next.js, Obsidian Help, Mintlify, Astro |
| Content is visual (charts, media, events) | The content is the image | Our World in Data, Spotify, Luma |
| Mixed (most real sites) | Fallback chain (section 2) | Linear |
If unsure, build the fallback chain. It includes the others.
Resolve each page’s preview in this order. Stop at the first that works:
social_image). Use it raw.image, cover, hero). Show it in an image card (section 4), or raw if it already looks like a social card (section 5).og:image.A site-wide image belongs only at step 5, or inside the image card’s tile. It must never sit above step 4: that’s the “same picture on every page” failure.
The default for any page without a picture. Canvas 1200×630.
┌──────────────────────────────────────────────────────┐
│ [logo] Site name │ identity, top-left, ~28px
│ │
│ SECTION │ kicker, uppercase, ~22px, accent colour
│ Page title in the heading font, │ 54–76px, step down for long titles,
│ up to three lines │ clamp at ~90 chars
│ One-sentence description, two lines max │ ~28px, muted
│ │
│ example.com/path │ URL, bottom-left, ~22px
└──────────────────────────────────────────────────────┘
For pages with a picture: the text card with the image added, so the two read as one system.
Test it on real pages before committing to it (fixtures in section 9).
Use it as-is only if it already looks like a social card: aspect ratio 1.8–2.05 and at least 1000px wide. Authors who design their own 1200×630 (Josh Comeau, Every, Hugging Face) shouldn’t get it wrapped in another card. Everything else goes in the image card, so it isn’t cropped badly by each platform and the title stays visible.
The first image in the body is often a good preview (Gwern’s first figure, LessWrong) and sometimes a terrible one. Filter:
og:image are a common silent bug.Text beats a bad image. When in doubt, fall through to the text card.
<meta property="og:title" content="Page title">
<meta property="og:site_name" content="Site name">
<meta property="og:description" content="One human sentence.">
<meta property="og:image" content="https://example.com/_og/path?v=abc123"> <!-- absolute -->
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="What the image shows (the title is fine for text cards)">
<meta property="og:url" content="https://example.com/path">
<meta property="og:type" content="article">
<meta name="twitter:card" content="summary_large_image">
https URLs only. Always send width and height: some platforms lay out the card before fetching the image.og:title. og:site_name is the clean way; adding “ · Site” to the title is a common fallback.twitter:creator or similar per-person fields across a multi-author site.@vercel/og (JSX to PNG, as Next.js opengraph-image does);.webp cards);?v=). Hash the inputs (title, description, section, the image’s file hash, theme and a template version). Serve immutable long-cache only when v matches; otherwise serve a short cache, so random v values can’t fill your cache.?v= to force a re-fetch.The two most common, both from the study: