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.

The sequence is short and unforgiving:

  1. The app fetches the URL — usually with a bot user agent, no cookies, no JavaScript execution.
  2. It parses the <head> for Open Graph and card metadata, and often the page content for a fallback description.
  3. It downloads and re-encodes og:image to its own size and crop.
  4. 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

TagStatusWhat breaks without it
og:titleRequiredCard falls back to the page title or the URL
og:descriptionRequiredEmpty card body, or scraped page text
og:imageRequiredText-only card, roughly half the click rate
og:urlRequiredCard may point at a staging or duplicate URL
og:typeRequiredDefaults to website; articles lose their metadata
og:site_nameRecommendedNo brand line above the title
og:localeRecommendedWrong language assumed for the audience
og:image:width, :heightRecommendedFirst render can be blank while the image loads
og:image:altRecommendedNo accessible description; some platforms pull odd text
twitter:cardRecommendedX shows a small square thumbnail instead of a large card

A complete, boring, correct block looks like this:

HTML — article head
<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.

PlatformRatio shownCrop notes
X (large card)1.91:1Full image on desktop; cropped toward the centre on narrow screens
Facebook1.91:1Crops from the centre on mobile feed
LinkedIn1.91:1Separate small square variant in some surfaces
Slack~1.91:1Unfurl is compact; text in the outer 10% is easy to miss
DiscordVariableWide embed; keeps the left portion on small windows
KakaoTalk~2:1Crops toward the centre, harder than the others
WhatsApp1.91:1Often 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:

JavaScript — edge OG image (illustrative)
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

  1. A relative image path. /og.png is resolved by the crawler against its own origin, not yours. Always absolute, always https.
  2. 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.
  3. Duplicate tags. An SEO plugin plus a hard-coded block plus a framework default produce three og:title elements. Crawlers generally take the first, but “generally” is not a specification, and the first is often the one you forgot about.
  4. 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.
  5. 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:

Shell — card audit
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:type present exactly once.
  • Image is absolute HTTPS, 1200×630, under 5 MB, with width, height and alt.
  • twitter:card is summary_large_image and 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.