DEV Community

Cover image for Your OG tags look fine in the browser and still break on Twitter — here's the part I got wrong
SHOTA
SHOTA

Posted on

Your OG tags look fine in the browser and still break on Twitter — here's the part I got wrong

I shipped a page, pasted the URL into Slack, and got a bare link. No thumbnail, no description, just blue text. The tags were right there in DevTools — og:title, og:description, og:image, all present, all populated.

That's the annoying class of bug: the thing you're inspecting and the thing the other system reads are two different documents.

The document you inspect isn't the document crawlers get

Social crawlers — facebookexternalhit, Twitterbot, Slackbot-LinkExpanding — fetch your URL and parse the HTML that comes back. They don't run your JavaScript. So if your framework sets meta tags client-side, or a router swaps them on navigation, the crawler sees whatever the server sent, which may be an empty template.

I knew this in the abstract and still built the bug into my own tool. My first version of MetaPreview read the live DOM:

// this reads the page AFTER your JS has run
const tag = (p) => document.querySelector(`meta[property="${p}"]`)?.content ?? null;

const preview = {
  title: tag('og:title'),
  description: tag('og:description'),
  image: tag('og:image'),
};
Enter fullscreen mode Exit fullscreen mode

Every field came back populated, so the extension drew a nice preview card and I believed it. Meanwhile Slack was unfurling nothing, because the server-rendered HTML for that route had a placeholder <title> and no og:* at all.

The tool was confidently green on a page that was broken. That's worse than no tool.

The fix: compare the DOM against the pre-JS HTML

The check that actually answers the question is a diff between two documents — the one in front of you, and the one a crawler would receive:

async function crawlerView(url) {
  // same-origin fetch of your own URL, parsed WITHOUT executing scripts
  const res  = await fetch(url, { credentials: 'omit' });
  const html = await res.text();
  const doc  = new DOMParser().parseFromString(html, 'text/html');
  const pick = (p) =>
    doc.querySelector(`meta[property="${p}"], meta[name="${p}"]`)?.content ?? null;
  return { title: pick('og:title'), description: pick('og:description'), image: pick('og:image') };
}

const live = { title: tag('og:title'), description: tag('og:description'), image: tag('og:image') };
const crawler = await crawlerView(location.href);

for (const k of Object.keys(live)) {
  if (live[k] !== crawler[k]) {
    console.warn(`[${k}] browser sees ${JSON.stringify(live[k])}, crawler sees ${JSON.stringify(crawler[k])}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

DOMParser doesn't execute scripts, which is the whole point here — it gives you the same inert parse a crawler does. When those two objects disagree, the DOM is lying to you about what gets shared.

This isn't a perfect model of a crawler. It won't follow the redirect chain a crawler follows, and it won't reproduce per-bot User-Agent branching if your server does that. But it caught my actual bug on the first run, and every SPA route I've pointed it at since.

Two smaller traps I hit on the way

Relative og:image doesn't resolve. In the browser, /images/card.png renders fine because the browser resolves it against the current document. Crawlers want an absolute URL and several of them just drop a relative one. One line fixes it, and it's worth doing in the checker rather than trusting yourself to remember:

const abs = (src) => (src ? new URL(src, document.baseURI).href : null);
// '/images/card.png' -> 'https://example.com/images/card.png'
Enter fullscreen mode Exit fullscreen mode

Character counts don't mean what .length means. Titles and descriptions get truncated at different points on each of the four platforms I care about — Twitter/X, Facebook, LinkedIn, Slack — and String.prototype.length counts UTF-16 code units, not what the truncation is actually applied to. A Japanese title that reads as comfortably short in .length terms can still be cut, and an emoji in the middle of a title counts as two:

'🚀'.length;              // 2  — one emoji, two code units
[...'🚀'].length;         // 1  — actual characters
'日本語のタイトル'.length;   // 8  — characters, but bytes are 3x that in UTF-8
new TextEncoder().encode('日本語のタイトル').length; // 24
Enter fullscreen mode Exit fullscreen mode

So the warning has to be computed against the right unit, and the right unit isn't the same everywhere. Getting this wrong is silent: the preview looks fine locally and gets clipped in the feed.

What the extension does now

MetaPreview renders the card side by side for Twitter/X, Facebook, LinkedIn, and Slack, shows og:title, og:description, og:image, og:url and twitter:card in a raw table, warns when a title or description is long enough to get truncated, and runs a validation checklist for missing or malformed tags.

The crawler diff above isn't in the extension — that one I still run by hand in the console, and it's the check I'd add first.

On the data question, since people reasonably ask: it reads the meta tags of the page you're on, and it sends anonymous usage analytics through Google Analytics 4. I'd rather state both than write a blanket "collects nothing" line I'd have to walk back.

It's on the Chrome Web Store, and the extension page is at dev-tools-hub.xyz/extensions/metapreview.

The part I keep coming back to is that the bug wasn't in the meta tags. It was in which copy of the page I was looking at — and my own tool picked the wrong one for weeks.

What do you use to check unfurls before you ship — a hosted debugger, curl with a bot User-Agent, or something in the browser?

Top comments (0)