Nobody clicks a link. They click a card: a picture, a headline and a domain, rendered by someone else's software from tags you wrote months ago and have not looked at since.
That card is the entire first impression for every share on X, LinkedIn, Facebook, Slack, Discord, WhatsApp and KakaoTalk — and it is built by a crawler that will not run your JavaScript, will not log in, and will give up after a few seconds. Most broken previews are not cosmetic. They are the difference between a link that gets read and one that scrolls past.
This guide covers what the crawlers actually do, the tags that matter and the ones that do not, image rules per platform, dynamic cards at the edge, and the five bugs responsible for the majority of blank previews.
What happens when a link is pasted
The sequence is short and unforgiving:
- The app fetches the URL — usually with a bot user agent, no cookies, no JavaScript execution.
- It parses the
<head>for Open Graph and card metadata, and often the page content for a fallback description. - It downloads and re-encodes
og:imageto its own size and crop. - It stores the result. Sometimes for a week.
Four consequences follow directly. The URL must return 200 to the crawler without a redirect chain, since some crawlers do not follow more than one hop. The tags must be in the initial HTML, because client-side rendering happens after the fetch. The image must be publicly reachable — a signed URL or a CDN protected by a referer check will fail. And the result is cached, so fixing the tags does not fix the cards people have already seen until you force a re-scrape.
The tags that matter
| Tag | Status | What breaks without it |
|---|---|---|
og:title | Required | Card falls back to the page title or the URL |
og:description | Required | Empty card body, or scraped page text |
og:image | Required | Text-only card, roughly half the click rate |
og:url | Required | Card may point at a staging or duplicate URL |
og:type | Required | Defaults to website; articles lose their metadata |
og:site_name | Recommended | No brand line above the title |
og:locale | Recommended | Wrong language assumed for the audience |
og:image:width, :height | Recommended | First render can be blank while the image loads |
og:image:alt | Recommended | No accessible description; some platforms pull odd text |
twitter:card | Recommended | X shows a small square thumbnail instead of a large card |
A complete, boring, correct block looks like this:
<meta property="og:type" content="article">
<meta property="og:site_name" content="Pikkola Journal">
<meta property="og:title" content="The Modern Guide to Web-Based Text Diffing">
<meta property="og:description" content="What a browser diff engine actually needs: LCS limits, word-level passes, and JSON Patch.">
<meta property="og:url" content="https://blog.pikkola.com/posts/web-text-diffing-and-json-patch">
<meta property="og:image" content="https://blog.pikkola.com/og/text-diff.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="A diff view showing three added and one removed line">
<meta property="og:locale" content="en_US">
<meta property="article:published_time" content="2026-11-06T09:00:00+09:00">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="The Modern Guide to Web-Based Text Diffing">
<meta name="twitter:description" content="What a browser diff engine actually needs, and where the textbook algorithm stops being useful.">
<meta name="twitter:image" content="https://blog.pikkola.com/og/text-diff.png">
How much of X's card can you actually control?
Less than people assume. X reads twitter:* first and falls back to og:* for title, description and image, so duplicating both is a reasonable defensive default. twitter:site and twitter:creator render the @handle in some layouts and nothing in others; setting them is harmless, relying on them is not. And X's card layout is not yours — a title over about 70 characters is truncated on mobile whether or not your tags allow it.
Image rules, per platform
One image, 1200×630 (1.91:1), JPEG or PNG, under 5 MB, at an absolute HTTPS URL. That covers every platform in the table below without a single exception. Everything beyond that is crop management.
| Platform | Ratio shown | Crop notes |
|---|---|---|
| X (large card) | 1.91:1 | Full image on desktop; cropped toward the centre on narrow screens |
| 1.91:1 | Crops from the centre on mobile feed | |
| 1.91:1 | Separate small square variant in some surfaces | |
| Slack | ~1.91:1 | Unfurl is compact; text in the outer 10% is easy to miss |
| Discord | Variable | Wide embed; keeps the left portion on small windows |
| KakaoTalk | ~2:1 | Crops toward the centre, harder than the others |
| 1.91:1 | Often shows a cached or older image for hours |
Tip
Design the image with a 60 px margin of nothing on all sides and keep every word inside the centre 1000×520. Then test the crop by pasting the link into a real chat window — the preview you get there is the preview your audience gets.
Two more rules that are invisible until they bite: put the text in the image at a size that survives a 320 px-wide thumbnail (roughly 48 px or larger in the original), and never make the image's only distinguishing feature a colour difference. A card is often seen at thumbnail size in a feed with eleven others.
Dynamic cards at the edge
Hand-made images do not scale past a few dozen posts. Generating them per URL does, and the cost is now near zero: the crawler fetches /og?title=..., an edge function renders HTML to an image, caches it, and returns PNG.
On Cloudflare Workers, workers-og is the usual route; on Vercel it is @vercel/og, and both wrap the same HTML-to-SVG renderer. The shape of the handler is always the same:
import { ImageResponse } from "workers-og";
export default {
async fetch(request) {
const url = new URL(request.url);
const title = (url.searchParams.get("title") || "Pikkola").slice(0, 90);
const kicker = (url.searchParams.get("kicker") || "").slice(0, 40);
const html = `
<div style="display:flex;flex-direction:column;justify-content:space-between;
width:1200px;height:630px;padding:80px;background:#fbfbfa;color:#1d1c1a">
<div style="font:600 22px system-ui;letter-spacing:.16em;text-transform:uppercase;
color:#b4522b">${kicker}</div>
<div style="font:600 64px Georgia,serif;line-height:1.15;letter-spacing:-.02em">${title}</div>
<div style="font:400 26px system-ui;color:#7a746a">blog.pikkola.com</div>
</div>`;
return new ImageResponse(html, {
width: 1200,
height: 630,
headers: { "cache-control": "public, max-age=31536000, immutable" },
});
},
};
Three cautions. Escape the interpolated text or you have an injection point, even in an image generator. Keep the layout to what the renderer supports — flexbox, not grid, and a strict subset of CSS. And version the URL (/og?title=...&v=2) so that changing the design does not leave a year of immutable caches showing the old template. For reference, Pikkola Meta previews exactly how a given tag set will render across seven platforms, which is faster than editing tags and re-scraping to see the result.
Caching, and how to force a re-scrape
Platforms cache aggressively because fetching every pasted URL would be expensive. That means your fixed tags may not show up for a while, or ever, without a nudge:
- X — the Post Inspector re-reads a URL on demand; cards otherwise refresh after roughly a week.
- Facebook — Sharing Debugger, “Scrape Again”. This also powers previews inside Messenger and Instagram.
- LinkedIn — Post Inspector.
- Slack — no inspector; posting the link again in a channel is the only reliable trigger.
- Discord — cache by URL, including the query string; appending a harmless parameter forces a fresh read.
Note
Versioning the image URL is the cheapest cache-busting tool you have: og.png?v=2 is a different URL to every crawler, and it lets you fix a card without touching the page's canonical URL.
The five bugs behind most blank cards
- A relative image path.
/og.pngis resolved by the crawler against its own origin, not yours. Always absolute, alwayshttps. - Tags added by client-side JavaScript. React Helmet, Vue Meta and Next.js's client router all produce correct tags in the browser and nothing in the first response. For a single-page app, the route must be server-rendered or pre-rendered, or the card will be blank.
- Duplicate tags. An SEO plugin plus a hard-coded block plus a framework default produce three
og:titleelements. Crawlers generally take the first, but “generally” is not a specification, and the first is often the one you forgot about. - A staging URL in
og:url. Cards then point at a host that does not resolve outside the office, and analytics attribute the click to nowhere. - A redirect, a login, or a robots block. Any of the three can make the fetch fail silently, and a failed fetch produces an empty card — never an error message.
Verify from the command line
Ten seconds of curl catches everything above except the rendering. Run it before every launch, and again after any framework upgrade:
url="https://blog.pikkola.com/posts/open-graph-twitter-cards-guide"
# 1. Status, with redirects visible
curl -sSIL "$url" | grep -E "HTTP/|location:"
# 2. Tags in the first response, no JavaScript
curl -sSL "$url" | grep -oE '<meta (property|name)="(og|twitter|article):[^"]+" content="[^"]*"'
# 3. The image itself: type, size, dimensions
curl -sSI "https://blog.pikkola.com/og/text-diff.png" | grep -iE "content-type|content-length"
If step 2 returns fewer than eight lines, you have found the problem. If step 3 returns an HTML content type, the image URL is 404ing into your error page — which is the single most common cause of a mysterious blank card.
Measure something, or stop optimising
Cards are a conversion surface, not a decoration. Two numbers tell you whether they work: the click-through rate of a link that is posted in a channel you control (the impression count is visible, the clicks end up in your analytics), and the sessions attributed to each referrer domain. Tag utm_source per network on the links you post, keep the canonical clean, and compare the same post across networks. The differences between a 1.91:1 image and a 1:1 image are measurable this way; the ones you cannot measure are usually not worth a redesign.
A final checklist
- Every shareable page returns 200 without redirects to a bot user agent.
og:title,og:description,og:image,og:url,og:typepresent exactly once.- Image is absolute HTTPS, 1200×630, under 5 MB, with width, height and alt.
twitter:cardissummary_large_imageand the image URL matches.- Text inside the centre 1000×520; nothing important within 60 px of the edge.
- No duplicate tags from plugins, themes or framework defaults.
- The card has been re-scraped since the last change.
- A curl check is part of the deploy checklist, not a debugging step after launch.