Frontend, chapter 08 of 08
Link Previews & Social Cards
How a page looks when shared, unfurled or installed: Open Graph, X Cards, each unfurler's rules, oEmbed, generated images, icons, the manifest and theme color.
Most people meet a page as a preview before they ever load it: a card in a chat thread, a post on a social feed, an icon on a home screen. That preview is built by someone else's software from a few tags in your <head>, fetched once, cached for anywhere from half an hour to indefinitely, and usually without running a line of your JavaScript. When the tags are missing or wrong, the link shows as a bare URL, the wrong image, or last month's title, and there's no error anywhere to tell you.
This is a separate concern from SEO. Search engines rank pages; unfurlers only describe them. The tags overlap, but the consumers, their caches and their failure modes don't.
How a Preview Is Built#
Every unfurler documents roughly the same pipeline: a server-side fetch of the URL, a parse of the HTML <head>, a fetch of the image, then a cache. The details that matter, from each vendor's own docs as of October 2026:
- No JavaScript. Discord's fetcher runs none (Discord: Link previews), and Apple's Messages fetcher doesn't follow meta redirects or run JavaScript (Apple TN3156). Tags injected on the client don't exist for these consumers, so they must be in the server-rendered HTML.
- Tags early in the document. Meta reads Open Graph tags within "the first 1 MB" (Meta: Web crawlers); WhatsApp wants the
<head>"within the first 300KB" (WhatsApp: Link previews). Inline scripts and styles ahead of the meta tags can push them out of range. - Real status codes and fast responses. Discord follows at most 5 redirects and gives up after 10 seconds in total, including measuring images; Meta wants content "within a few seconds."
- Absolute URLs. Open Graph defines URL values as
http://orhttps://URLs, and every example is absolute (ogp.me); WhatsApp requiresog:imageto be absolute. A relativeog:imageis the most common reason a preview has no picture.
Open Graph#
The Open Graph protocol is the format nearly every unfurler reads first. Four properties are required: og:title, og:type, og:image and og:url.
<meta property="og:title" content="SEO">
<meta property="og:type" content="article">
<meta property="og:url" content="https://example.com/docs/seo">
<meta property="og:image" content="https://example.com/og/seo.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="The words SEO on a dark background">
<meta property="og:description" content="Search engine optimization for engineers.">
<meta property="og:site_name" content="Example">
<meta property="og:locale" content="en_US">
og:typeiswebsiteby default, orarticle,book,profile, and themusic.*andvideo.*families.articleaddsarticle:published_time,article:authorand friends.og:imagehas structured properties::width,:height,:type,:secure_urland:alt. The spec says a page withog:image"should specifyog:image:alt," describing the image rather than captioning it. Declaring width and height lets Discord skip a measuring fetch and helps Meta render the image the first time it's shared.og:localeuseslanguage_TERRITORY(en_US, the default), withog:locale:alternatefor other languages of the same page. Note the underscore, unlike the hyphenated BCP 47 tags everywhere else.- Arrays are repeated tags. When a property conflicts, the first one wins, and a structured property (
og:image:width) belongs to the most recentog:imageabove it. Discord turns two or moreog:imagetags into a gallery of up to four. og:site_nameis where branding goes. Apple's guidance is explicit: "Do not put the site name or other branding in theog:title" (Apple TN3156).
X Cards#
X reads its own twitter: tags and falls back to Open Graph for anything missing, so the only tag most pages need is the card type:
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@example">
As of October 2026, X documents four card types (summary, summary_large_image, app and player), one per page. Card images are JPG, PNG, WebP or GIF (first frame only), up to 5 MB, and SVG isn't supported; summary_large_image is 2:1, from 300 by 157 up to 4096 by 4096. Its crawler identifies as Twitterbot, respects robots.txt (a blocked page shows no card), and caches a card for 7 days after the link is first posted. I couldn't open X's developer pages to confirm these, so treat the figures as a starting point and check the current docs. Other consumers read twitter: tags too: Slack, Discord and Apple all document reading X Card metadata alongside Open Graph, and twitter:card with summary_large_image is what makes Discord show the large image.
Images#
The image is most of the preview, and every consumer sizes it differently. What each one documents, as of October 2026:
| Consumer | Recommended | Minimum | Aspect | Max file |
|---|---|---|---|---|
| Meta (Facebook) | at least 1200 x 630 | 600 x 315 for the large layout, 200 x 200 at all | about 1.91:1 | 8 MB |
| 1200 x 627; under 401 px wide shows as a thumbnail | 1.91:1 | 5 MB | ||
| 300 px wide | 4:1 or less | under 600 KB | ||
| Apple Messages | 900 px wide; under 150 px may show as an icon | 1 MB main resource, 10 MB total | ||
| Discord | PNG, GIF, JPEG, WebP or AVIF | |||
X (summary_large_image) |
300 x 157 | 2:1 | 5 MB |
My default is one image per page at 1200 by 630 pixels, PNG or JPEG, under 600 KB. That's Meta's recommended size and the default for Next.js's generated images, it clears LinkedIn's and Apple's minimums, sits within a few pixels of 1.91:1, and stays under WhatsApp's file limit, the tightest one. X crops it slightly toward 2:1, so I keep text and faces inside a centered safe area rather than near the edges. Apple says to "avoid text in preview images," since the title is already shown beside it; I keep any text large and short for the same reason.
How Each Unfurler Behaves#
What each consumer documents about its fetch, its cache and how to refresh it, as of October 2026:
| Consumer | User agent | Reads | Cache and refresh |
|---|---|---|---|
| Meta (Facebook, Messenger) | facebookexternalhit/1.1, among other meta-* agents |
Open Graph, fb:app_id |
Images cached by URL and "won't be updated unless the URL changes"; Sharing Debugger forces a re-scrape |
WhatsApp/2.x.x.x A|I|N |
og:title, og:description, og:url, og:image |
Not documented; test by typing the link without sending | |
| Not documented | og:title, og:image, og:description, og:url, and oEmbed |
Post Inspector; LinkedIn says to allow 48 hours from the last share (help) | |
| Slack | Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots) |
oEmbed, X Cards, Open Graph | Cached "globally for about 30 minutes"; doesn't honor robots.txt |
| Discord | Not documented | Open Graph, X Cards, theme-color, oEmbed (JSON only) |
About 30 minutes; change the URL (?v=2) to bust it; Embed Debugger |
| Apple Messages | Not documented; serve the same tags to every agent | og:title, og:site_name, og:image, og:video, icons |
Not documented |
| Telegram | Not documented | Not documented | Send the link to @WebpageBot for an updated preview |
| X | Twitterbot |
X Cards, then Open Graph | 7 days (unconfirmed, see above) |
LinkedIn, Discord, Apple and Telegram don't publish their preview fetchers' user agents or (for Telegram) the tags they read, so I don't build anything that depends on recognizing them. A few vendor details are worth knowing on their own:
- Meta's crawlers need gzip and deflate support, and both
meta-externalfetcherand, for security checks,facebookexternalhitmay bypass robots.txt. Allow up to 24 hours for a robots.txt change to be seen (Meta: Web crawlers). A re-scrape refreshes the page's tags, but an image at an unchanged URL stays cached, so a new image needs a new URL. - Discord's limits are a title of 70 bytes (3 lines), a description of 350 bytes (20 lines), a site name of 256 characters and URLs of 2,048. It reads at most 50 MB and needs a
2xxwith atext/htmlorapplication/xhtml+xmltype (Discord: Link previews). - Apple Messages wants square icons of at least 108 px, follows server-side redirects but not meta refreshes, and plays
og:videoortwitter:player:streaminline only when it points at a direct media file. It notes the "limits may change in the future" (Apple TN3156). - Slack fetches with
Rangeheaders and caches globally, so a fix takes up to half an hour to show everywhere (Slack: Robots).
Because so many caches key on the exact URL, my rule is that the image URL changes whenever the image does: a content hash or version in the path (/og/seo.png?v=3 or /og/seo-3f2a.png). It's the only refresh that works across every consumer at once.
oEmbed#
oEmbed is the other route to a rich preview, used for embeddable media: the page advertises a JSON (or XML) endpoint, and the consumer asks it for embed details.
<link rel="alternate" type="application/json+oembed"
href="https://example.com/oembed?url=https%3A%2F%2Fexample.com%2Fv%2F42&format=json"
title="Product demo">
The response always has type (photo, video, link or rich) and version ("1.0"); photo adds url, width and height, while video and rich add html, width and height. Optional fields include title, author_name, provider_name, cache_age and a thumbnail (all three of thumbnail_url, thumbnail_width and thumbnail_height together). Providers must honor the consumer's maxwidth and maxheight, and the spec says rich HTML should be served in an iframe on another domain to "avoid XSS." Slack, Discord and LinkedIn all read oEmbed. The spec keeps a registry of providers at providers.json.
I add oEmbed only for things people embed (video, audio, interactive widgets); for an ordinary page, Open Graph is enough.
Generating Images Dynamically#
A site with thousands of pages can't hand-design thousands of images, so the usual approach is a template rendered to PNG on demand from the page's title and metadata. In Next.js, an opengraph-image.tsx file next to a route generates its image with ImageResponse from next/og, which defaults to 1200 by 630 (Next.js: ImageResponse):
// app/docs/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getDoc } from "@/lib/docs";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export const alt = "Documentation page title card";
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const doc = await getDoc(slug);
return new ImageResponse(
<div style={{ display: "flex", width: "100%", height: "100%", padding: 80,
alignItems: "flex-end", fontSize: 72, background: "#0b1211", color: "#fff" }}>
{doc.title}
</div>,
size,
);
}
The constraints are worth knowing before designing the template, per the Next.js and Vercel OG docs as of October 2026:
- A CSS subset with flexbox layout.
ImageResponsesupports "only flexbox and a subset of CSS";display: grid"will not work." Satori, the renderer underneath, now documents Grid and WOFF2 support in its own README (Satori), but the version bundled innext/ogmay lag, so I design to the Next.js page. - Fonts in TTF, OTF or WOFF, loaded explicitly; there are no system fonts.
- A 500 KB bundle limit, including fonts and images.
- Size limits on the output: Next.js fails the build for an
opengraph-imageover 8 MB or atwitter-imageover 5 MB (Next.js: opengraph-image). - Static unless it reads request data. An image that uses only route params and content is generated at build time; one that reads cookies or headers renders per request.
Whatever the framework, my rules for generated images:
- Cache aggressively, version by content. The image only changes when the title does, so it's built at publish time or served with a long
Cache-Controlbehind a URL that includes a content version (Caching). Generating on every unfurl invites a crawler stampede on a popular link. - Fall back to a static site image. If generation fails or times out, the route returns a default image rather than an error, because a slow or broken image response loses the whole preview (Discord's 10-second budget includes measuring the image).
- Don't block the route. The image URL must be fetchable by crawlers; Vercel's docs suggest allowing the image route in robots.txt.
- Escape and truncate input. Titles come from content, sometimes user content; the template clips long text instead of overflowing, and renders emoji and non-Latin scripts with fonts that include them.
Favicons and App Icons#
Icons show up in browser tabs, search results, bookmarks, home screens and next to link previews (Apple uses an apple-touch-icon, a favicon or another <link rel> icon). A minimal, complete set:
<link rel="icon" href="/favicon.ico" sizes="32x32">
<link rel="icon" href="/icon.svg" type="image/svg+xml" sizes="any">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="manifest" href="/manifest.webmanifest">
- Browsers choose among
rel="icon"links bymedia,typeandsizes, using the last one on a tie;sizes="any"marks a vector format like SVG (MDN: rel, MDN: link). I always ship a raster ICO or PNG too, since not every consumer reads SVG. - iOS ignores
rel="icon"for home screen icons and usesapple-touch-icon. Apple's guide lists 180 by 180 for Retina iPhones, and falls back to "the smallest icon larger than the recommended size" (Apple: Configuring Web Applications). One 180-pixel PNG with an opaque background covers it. - Google Search shows one favicon per hostname (subdomains can have their own, subdirectories can't), square, at least 8 by 8 and ideally larger than 48 by 48, at a stable URL Googlebot can reach. Changes take "several days to several weeks" to show (Google: Favicon in Search).
Web App Manifest#
The web app manifest describes the site as an installable app: name, short_name, start_url, display (fullscreen, standalone, minimal-ui or the default browser), background_color for the launch screen, theme_color, and icons. It's still a W3C Working Draft as of October 2026. Chromium wants at least a 192 by 192 and a 512 by 512 icon to offer installation (web.dev: Add a manifest).
{
"name": "Example Docs",
"short_name": "Docs",
"start_url": "/",
"display": "standalone",
"background_color": "#0b1211",
"theme_color": "#0b1211",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
Icon purpose is any (the default), maskable or monochrome. A maskable icon is cropped by the platform into its own shape, so everything important has to sit inside a centered circle with a radius of 40% of the icon's width (web.dev: Maskable icons). Give it a separate file rather than "any maskable" on one image: web.dev's maskable guide advises against combining purposes, because an icon padded for masking looks too small where it isn't masked, even though its manifest article still shows the combined form.
Theme Color and Color Scheme#
<meta name="theme-color">tints browser chrome on supporting browsers, and accepts amediaattribute for light and dark values. Support is limited, so treat it as an enhancement (MDN: theme-color). It doubles as Discord's embed accent color, which must be#RRGGBBor#RRGGBBAA, and the manifest'stheme_colorshould match it.<meta name="color-scheme" content="light dark">tells the browser which schemes the page supports before CSS loads, so built-in UI such as form controls and scrollbars can match from the first paint (MDN: color-scheme). Values arenormal,light,dark,light dark,dark lightoronly light.
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0b1211" media="(prefers-color-scheme: dark)">
<meta name="color-scheme" content="light dark">
Both values come from the same design tokens as the CSS, so the browser chrome and the page can't disagree.
Site Defaults, Page Overrides#
Every page needs a complete set of tags, and most pages will never get hand-written ones, so the structure is a site-wide default with per-page overrides:
- The layout sets defaults for
og:site_name,og:locale,twitter:card,twitter:site, a defaultog:image, the icons andtheme-color. - Each page sets its
og:title,og:description,og:url(matching the canonical URL),og:type, and its own image when it has one. - The defaults are complete on their own, so a page that sets nothing still previews properly.
- Check how the framework merges. In Next.js, metadata merges shallowly: a page that sets
openGraphreplaces the layout's wholeopenGraphobject, so a page that sets only a title loses the default image unless it spreads the shared fields back in. Relative image URLs needmetadataBase, and using one without it fails the build (Next.js: generateMetadata). Next.js also keeps metadata in the initial<head>for crawlers it knows can't wait for streamed HTML, such asfacebookexternalhit, configurable withhtmlLimitedBots.
I generate the title and description for previews from the same fields as the <title> and meta description, so search, tabs and chat all describe the page the same way.
Private Content#
Unfurlers fetch URLs from private conversations without any of the viewer's cookies, and some ignore robots.txt (Slack says it "does not honor robots.txt"; Meta says two of its agents may bypass it). Treat every URL that gets pasted anywhere as one an anonymous stranger will fetch:
- Authenticated pages carry generic tags. A logged-out request to a private URL gets the login page with site-level Open Graph tags, never the record's title, a customer's name or a document's first line. Rendering per-record metadata before checking authorization is a data leak, even if the body is protected.
- Shareable links are a decision. If a signed or unlisted link is meant to be shared, decide what its preview may show (often a title and a generic image) and render exactly that; everything in the tags is visible to anyone in the channel.
- Fetches must be safe. An unfurler's
GETlooks like a click. Magic sign-in links, invitations and one-time downloads must not be consumed by aGETalone; confirm with aPOSTfrom the page, so a preview fetch can't spend them. - No secrets in URLs. Previews, caches and debugger tools all store the URL itself.
Testing and Validation#
- In CI, render each page template and assert the tags: all four required Open Graph properties present, absolute URLs, an
og:imagethat returns200with an image content type and size within the limits above,og:image:altset, and authenticated routes returning only defaults. - The vendor tools show what each consumer actually parsed, and the Meta and LinkedIn tools also refresh their caches: Meta's Sharing Debugger, LinkedIn's Post Inspector, Discord's Embed Debugger, Telegram's @WebpageBot, and for WhatsApp, typing the link into a chat without sending and waiting up to 10 seconds.
- Icons and manifest: the browser's DevTools application panel shows the parsed manifest, icons and installability errors.
- After changing an image, change its URL; after changing tags, re-scrape in the tools above, and expect Slack and Discord to catch up within about half an hour on their own.
Checklist#
- Every page's
<head>hasog:title,og:type,og:url,og:image(absolute),og:image:alt, width and height,og:descriptionandog:site_name, in the server-rendered HTML, near the top. twitter:cardissummary_large_imagefor pages with a meaningful image; othertwitter:tags fall back to Open Graph.- Images are 1200 by 630, PNG or JPEG, under 600 KB, with important content in a centered safe area.
- Image URLs change when the image does; generated images are cached and fall back to a static default.
- Site defaults are complete; per-page tags override them without dropping fields.
- Logged-out requests to private URLs return only generic tags; one-time links can't be consumed by a
GET. - oEmbed is offered for embeddable media only.
- Icons: an ICO or PNG favicon, an optional SVG, a 180-pixel
apple-touch-icon, and manifest icons at 192 and 512 plus a separate maskable icon. - The manifest,
theme-colorandcolor-schemecome from the same tokens as the CSS. - Tags are asserted in CI per template, and checked in each vendor's tool before a launch.
References#
Specifications
- The Open Graph protocol, required and optional properties, structured image properties, types and arrays
- oEmbed, discovery, response types and fields, and the security note on rich HTML
- oEmbed: providers.json, the provider registry
- W3C: Web Application Manifest, manifest members, display modes and icon purposes
Unfurlers
- Meta: A guide to sharing for webmasters, basic tags, image caching and the Sharing Debugger
- Meta: Images in shared links, recommended and minimum image sizes, aspect ratio and file size
- Meta: Web crawlers, user agents, the 1 MB limit, compression and robots.txt behavior
- WhatsApp: Link previews, required tags, image limits, the 300 KB head limit and the user agent
- LinkedIn Help: Link preview tags and images, tags read and image sizes
- LinkedIn Help: Refreshing shared URL previews, oEmbed and Open Graph, and the 48-hour refresh
- Slack: Robots, the Slackbot user agent, tags read, caching and robots.txt
- Slack: Unfurling links in messages, classic unfurls from Open Graph and X Card metadata
- Discord: Link previews, fetch limits, tags read, theme color, image formats, field limits and caching
- Apple: TN3156 Create rich previews for Messages, tags read, icon and image sizes, resource limits and fetch behavior
- Telegram: Link preview tips, refreshing previews with @WebpageBot
Generating images
- Next.js: opengraph-image and twitter-image, file conventions and size limits
- Next.js: ImageResponse, default size, CSS subset, fonts and the bundle limit
- Next.js: generateMetadata, metadataBase, shallow merging and HTML-limited bots
- Vercel: OG image generation, recommended size, limits and caching
- Vercel: Satori, the HTML and CSS to SVG renderer and its supported CSS
Icons and theme
- MDN: rel attribute, icon selection and apple-touch-icon on iOS
- MDN: link element, the sizes attribute
- Apple: Configuring Web Applications, apple-touch-icon sizes and fallback
- Google: Favicon in Search results, size, scope and update timing
- web.dev: Add a web app manifest, required members and install icon sizes
- web.dev: Adaptive icon support with maskable icons, the safe zone and separate purposes
- MDN: theme-color, values, the media attribute and support
- MDN: color-scheme, supported values and behavior
Tools
- Meta: Sharing Debugger, inspecting and re-scraping a URL
- LinkedIn: Post Inspector, inspecting a URL's LinkedIn preview
- Telegram: @WebpageBot, refreshing a Telegram preview