OG Image Not Showing? 12 Causes and a Checker Script
OG image not showing in Slack, LinkedIn or X? Twelve causes in the order to check them, a tested Node script that finds each one, and the fix for every case.
When an OG image is not showing, the cause is almost always that the crawler sees something different from what your browser sees. The crawler reads the raw HTML your server sends, without running your JavaScript, without your cookies, from an IP address your firewall may not trust, and with a few seconds to finish. Fetch the page the way the crawler does and the cause usually shows up in the first minute:
curl -sA 'facebookexternalhit/1.1' https://example.com/your-post | grep -i 'og:image'
If that prints nothing, stop here: the tag is not in the server HTML (cause 1 below). If it prints a tag, the problem is the tag's value or the image behind it. Work through the twelve causes below in order. They are ranked by how cheap they are to check, not by how often they happen; we have no data on frequency and will not guess. The guide to generating OG images covers how to build cards that avoid most of these problems in the first place.
Tested on 2026-10-18 with Node 22.18 on macOS. The checker script below was run against
https://example.comand against a local test server with one broken setup per path (13 paths, listed in the results table). No social platform was called; platform limits come from Meta's and LinkedIn's developer documentation, checked the same day.
The checker script
This script does what a link-preview crawler does: it fetches the page with Facebook's crawler user agent, follows redirects one hop at a time so it can count them, reads the <meta> tags from the HTML with scripts removed, and then fetches the image and reads its real pixel size from the file header. It has no dependencies.
// check-og.mjs: fetch a page the way a link-preview crawler does and report why og:image may not show. // Usage: node check-og.mjs https://example.com/post const UA = 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)'; const target = process.argv[2]; if (!target) { console.error('usage: node check-og.mjs <url>'); process.exit(2); } const problems = []; const warn = (msg) => problems.push(msg); // Follow redirects by hand so we can count them. async function fetchChain(url, { method = 'GET', max = 10 } = {}) { const hops = []; for (let i = 0; i <= max; i++) { const started = performance.now(); const res = await fetch(url, { method, redirect: 'manual', headers: { 'user-agent': UA, accept: '*/*' }, signal: AbortSignal.timeout(10_000) }); const ms = Math.round(performance.now() - started); hops.push({ url, status: res.status, ms }); const next = res.headers.get('location'); if (res.status >= 300 && res.status < 400 && next) { url = new URL(next, url).href; continue; } return { res, hops, finalUrl: url }; } throw new Error(`more than ${max} redirects`); } const decode = (s) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>'); function metaTags(html) { const tags = []; for (const m of html.matchAll(/<meta\b[^>]*>/gi)) { const attr = (n) => m[0].match(new RegExp(`\\b${n}\\s*=\\s*("([^"]*)"|'([^']*)')`, 'i')); const pick = (n) => { const a = attr(n); return a ? decode(a[2] ?? a[3]) : null; }; tags.push({ property: pick('property'), name: pick('name'), content: pick('content'), index: m.index }); } return tags; } function imageSize(buf) { if (buf.length > 24 && buf.readUInt32BE(0) === 0x89504e47) return { type: 'png', w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) }; if (buf.length > 10 && buf.toString('ascii', 0, 3) === 'GIF') return { type: 'gif', w: buf.readUInt16LE(6), h: buf.readUInt16LE(8) }; if (buf.length > 30 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') { const kind = buf.toString('ascii', 12, 16); if (kind === 'VP8X') return { type: 'webp', w: 1 + buf.readUIntLE(24, 3), h: 1 + buf.readUIntLE(27, 3) }; if (kind === 'VP8 ') return { type: 'webp', w: buf.readUInt16LE(26) & 0x3fff, h: buf.readUInt16LE(28) & 0x3fff }; if (kind === 'VP8L') { const b = buf.readUInt32LE(21); return { type: 'webp', w: (b & 0x3fff) + 1, h: ((b >> 14) & 0x3fff) + 1 }; } } if (buf.length > 4 && buf[0] === 0xff && buf[1] === 0xd8) { let i = 2; while (i + 9 < buf.length) { if (buf[i] !== 0xff) { i++; continue; } const marker = buf[i + 1]; if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) return { type: 'jpeg', w: buf.readUInt16BE(i + 7), h: buf.readUInt16BE(i + 5) }; i += 2 + buf.readUInt16BE(i + 2); } } return null; } async function robotsBlocks(url, agent) { const u = new URL(url); try { const res = await fetch(new URL('/robots.txt', u), { signal: AbortSignal.timeout(5_000) }); if (!res.ok) return false; const groups = []; let current = null; for (const raw of (await res.text()).split('\n')) { const line = raw.replace(/#.*/, '').trim(); const [k, ...rest] = line.split(':'); const v = rest.join(':').trim(); if (/^user-agent$/i.test(k)) { if (!current || current.rules.length) groups.push(current = { agents: [], rules: [] }); current.agents.push(v.toLowerCase()); } else if (current && /^(dis)?allow$/i.test(k)) current.rules.push({ allow: /^allow$/i.test(k), path: v }); } const group = groups.find((g) => g.agents.includes(agent.toLowerCase())) ?? groups.find((g) => g.agents.includes('*')); if (!group) return false; const match = group.rules.filter((r) => r.path && u.pathname.startsWith(r.path)).sort((a, b) => b.path.length - a.path.length)[0]; return Boolean(match && !match.allow); } catch { return false; } } const page = await fetchChain(target); if (page.hops.length > 1) console.log(`page redirects: ${page.hops.map((h) => `${h.status} ${h.url}`).join(' -> ')}`); if (!page.res.ok) warn(`page answered HTTP ${page.res.status} to a crawler user agent`); // Drop scripts: a tag that JavaScript would add later is not in the HTML a crawler parses. const html = (await page.res.text()).replace(/<script\b[\s\S]*?<\/script>/gi, ''); const headEnd = html.search(/<\/head>/i); const tags = metaTags(html); const og = (p) => tags.filter((t) => t.property === p); const images = og('og:image').concat(og('og:image:url')); const nameOg = tags.filter((t) => t.name?.startsWith('og:')); if (nameOg.length) warn(`og: tags use name= instead of property= (${nameOg.map((t) => t.name).join(', ')})`); const propTw = tags.filter((t) => t.property?.startsWith('twitter:')); if (propTw.length) warn(`twitter: tags use property= instead of name= (${propTw.map((t) => t.property).join(', ')})`); if (!tags.some((t) => t.name === 'twitter:card')) warn('no <meta name="twitter:card">; X shows at most a small card'); if (headEnd > 0 && images.some((t) => t.index > headEnd)) warn('og:image is outside <head>; some crawlers stop reading at </head>'); if (images.length === 0) { warn('no og:image tag in the HTML the crawler received (client-side rendered tags are invisible to crawlers)'); } if (images.length > 1) console.log(`note: ${images.length} og:image tags; most platforms use the first`); const raw = images[0]?.content; if (raw) { let imageUrl; try { imageUrl = new URL(raw); } catch { warn(`og:image "${raw}" is not an absolute URL`); imageUrl = new URL(raw, page.finalUrl); } if (imageUrl.protocol !== 'https:' && imageUrl.hostname !== 'localhost') warn('og:image is not https'); if (await robotsBlocks(imageUrl.href, 'facebookexternalhit')) warn('robots.txt blocks facebookexternalhit from the image path'); if (await robotsBlocks(imageUrl.href, 'Twitterbot')) warn('robots.txt blocks Twitterbot from the image path'); const img = await fetchChain(imageUrl.href); const total = img.hops.reduce((s, h) => s + h.ms, 0); if (img.hops.length > 1) warn(`image URL redirects ${img.hops.length - 1} time(s): ${img.hops.map((h) => h.status).join(' -> ')}`); if (!img.res.ok) warn(`image answered HTTP ${img.res.status}`); const type = img.res.headers.get('content-type') ?? ''; if (!type.startsWith('image/')) warn(`image Content-Type is "${type}", not image/*`); const buf = Buffer.from(await img.res.arrayBuffer()); const dim = imageSize(buf); console.log(`image: ${img.res.status} ${type} ${buf.length} bytes ${dim ? `${dim.type} ${dim.w}x${dim.h}` : 'unknown format'} in ${total} ms`); if (buf.length > 5 * 1024 * 1024) warn('image is over 5 MB (LinkedIn and X limit)'); if (dim && (dim.w < 200 || dim.h < 200)) warn(`image is ${dim.w}x${dim.h}; Facebook ignores images under 200x200`); if (dim && Math.abs(dim.w / dim.h - 1.91) > 0.15) console.log(`note: aspect ratio ${(dim.w / dim.h).toFixed(2)}:1 will be cropped in 1.91:1 previews`); const w = Number(og('og:image:width')[0]?.content), h = Number(og('og:image:height')[0]?.content); if (!w || !h) warn('no og:image:width/height; Facebook may skip the image on the first share'); else if (dim && (w !== dim.w || h !== dim.h)) warn(`og:image:width/height say ${w}x${h}, the file is ${dim.w}x${dim.h}`); if (total > 5000) warn(`image took ${total} ms; unfurlers give up on slow images`); } for (const p of problems) console.log(`PROBLEM: ${p}`); if (problems.length === 0) console.log('no problems found'); process.exitCode = problems.length ? 1 : 0;
Run it with node check-og.mjs https://your-site.com/post. It exits with code 1 when it finds a problem, so you can also run it in CI against a staging URL after each deploy.
The script uses regular expressions on HTML, which is good enough for <meta> tags but is not a full parser. It does not log in, does not solve bot challenges, and only reads robots.txt rules for one user agent at a time. A clean result means the common causes are ruled out, not that every platform will show the card.
What the checker found on 13 broken setups
We built a small local server with one fault per path and ran the checker against each. The images are real PNG files generated by the server.
| Path | Fault we built in | What the checker printed |
|---|---|---|
/good |
none | no problems found |
/relative |
content="/img/card.png" |
og:image "/img/card.png" is not an absolute URL |
/name-attr |
<meta name="og:image"> |
og: tags use name= instead of property= |
/redirect |
image URL answers 302, then 301 | image URL redirects 2 time(s): 302 -> 301 -> 200 |
/wrong-type |
PNG bytes served as text/html |
image Content-Type is "text/html", not image/* |
/tiny |
150×150 image | image is 150x150; Facebook ignores images under 200x200 |
/mismatch |
tags say 1200×630, file is 1200×600 | og:image:width/height say 1200x630, the file is 1200x600 |
/robots |
image under a Disallow: /private/ path |
robots.txt blocks facebookexternalhit from the image path |
/slow |
image answers after 6 seconds | image took 6003 ms; unfurlers give up on slow images |
/in-body |
tags placed inside <body> |
og:image is outside <head> |
/spa |
tag added by JavaScript after load | no og:image tag in the HTML the crawler received |
/missing-image |
image path returns 404 | image answered HTTP 404 |
/no-size |
no width and height tags | no og:image:width/height |
Against https://example.com, the checker reported no og:image and no twitter:card, which is correct: that page has neither.
The twelve causes, in the order to check them
| # | Symptom | Cause | Fix |
|---|---|---|---|
| 1 | No image anywhere, curl shows no tag |
Tags are added by client-side JavaScript | Render meta tags on the server |
| 2 | Works on some platforms only | Relative or http:// image URL |
Absolute https:// URL |
| 3 | Works on Facebook, small card on X | name= / property= mixed up, or no twitter:card |
property for og:, name for twitter: |
| 4 | Tag present but ignored | Tag outside <head>, or after broken markup |
Move tags to the top of <head> |
| 5 | No title, no image, nothing | Page blocks the crawler (WAF, bot challenge, login) | Allow the crawler user agents on public pages |
| 6 | Title shows, image does not | Image URL returns 404, 401 or 403 | curl the image URL with no cookies |
| 7 | Image works in a browser only | Redirects on the image URL | Put the final URL in the tag |
| 8 | Image ignored, no error | Wrong Content-Type |
Serve image/png, image/jpeg or image/webp |
| 9 | Image ignored on one platform | robots.txt disallows the image path |
Allow the image path for crawler user agents |
| 10 | Image ignored or shown as a thumbnail | Too small, too large, or SVG | 1200×630 PNG or JPEG, under 5 MB |
| 11 | Image appears on the second share | Slow first response; image generated on demand | Warm the image on publish |
| 12 | Blank on the first Facebook share | No og:image:width and og:image:height |
Add both tags with the real size |
1. The tag is not in the server HTML
Single-page apps that set meta tags in the browser (with a client-side head manager or document.head code) produce pages whose server HTML has no og:image. Most preview crawlers do not run JavaScript, so they never see the tag. The /spa test shows this: the tag exists after the page runs, and the checker still reports none. Fix it with server rendering or prerendering of the <head>. The Next.js and Rails guides show server-side tags for those frameworks.
2. The URL is relative or not https
/img/card.png is a path, not a URL. Some crawlers resolve it against the page URL; others drop it. Write the full https:// address. In Rails and Next.js, set the base URL once (default_url_options, metadataBase) so every generated tag is absolute.
3. Wrong attribute names
Open Graph tags use property="og:image". Twitter tags use name="twitter:card". Parsers vary in how forgiving they are, so a page that works on one platform can fail on another. Without a twitter:card tag, X shows a small card at most. The sizes X expects for each card type are in the Twitter card image size guide.
4. Tags outside the head
Crawlers read the start of the document and many stop at </head>. Tags placed in <body> by a component, or tags that sit after a large inline script, can be skipped. Keep the Open Graph block near the top of <head>, before stylesheets and scripts.
5. The page blocks the crawler
A bot-protection rule, a geofence or a login wall answers the crawler with a challenge page or a 403. You see nothing wrong in your browser because you pass the check. Test with the crawler user agent from a server outside your network, and allow the documented crawler user agents (facebookexternalhit, Twitterbot, LinkedInBot, Slackbot, Discordbot) on public pages.
6. The image URL returns an error
In our crawl of 10,000 home pages, 3.4% of og:image URLs were broken, mostly with 404 and 403. The image may be behind authentication, deleted by a deploy, or served from a signed URL that has expired. With RenderScreenshot signed URLs, an old URL in cached HTML returns HTTP 401 with expired_signature, and a parameter added after signing returns invalid_signature. The curl -w '%{http_code}' check from cause 7 shows which one.
7. The image URL redirects
A route such as /og?slug=... that answers with a 302 to the real image works in a browser. Crawlers follow redirects with less patience, and they cache by the URL they were given. Put the final image URL straight into the tag. Check the chain with:
curl -sIL -A 'facebookexternalhit/1.1' "$IMAGE_URL" | grep -iE '^(HTTP|location|content-type)'
8. The Content-Type is wrong
Object storage often serves files without a type, as application/octet-stream, or as text/html when an error page answers. Set the type at upload time. The /wrong-type test shows that the bytes can be a valid PNG and the type header still wrong.
9. robots.txt blocks the image
A broad Disallow rule for an assets or API path also applies to preview crawlers that respect robots.txt. Add an Allow rule for the image path, or move the images out of the blocked path.
10. Size, file size or format
Meta's documentation (checked 2026-10-18) sets a 200×200 minimum, recommends at least 1200×630, and caps the file at 8 MB. LinkedIn lists a 1200×627 minimum and a 5 MB maximum. SVG is not a safe choice for any platform. A detailed per-platform table and the crop each preview shape applies are in the OG image size guide.
11. The first response is slow
An image generated on demand, on a server that has to start up first, can take longer than the preview crawler waits. The /slow test answered after 6 seconds; we set the checker's warning at 5 seconds as a rule of thumb, not as a documented platform limit. Warm the image when you publish: fetch its URL once from your server so the first crawler gets a cached copy. With RenderScreenshot, sign cache_available=true into the URL so later fetches inside the cache TTL reuse that render; the caching reference has the rules.
12. No width and height tags
Meta's documentation says the crawler has to see an image once before it can render it, and that og:image:width and og:image:height let it render the image right away on the first share. Add both, and make them match the file. A mismatch, as in the /mismatch test, makes the platform lay out the card for one shape and then receive another.
After the fix: clear the caches
Fixing the page does not update a preview that a platform already stored.
- Facebook: open the Sharing Debugger and choose "Scrape Again".
- LinkedIn: inspect the URL in Post Inspector, which fetches it again.
- X: compose a new post with the link to see the current card.
- Slack and Discord: old messages keep their old unfurl. Post the link again to check.
If the image itself changed but its URL did not, platforms may keep the old file. Change the image URL when the content changes, for example with a version parameter taken from the post's update time. Before the next launch, run through the social card checklist.
Skip the image hosting part
Causes 6 to 12 are all about serving an image file correctly: status, type, size, speed and stable URLs. If you would rather not host card images, RenderScreenshot renders a card page of your site with the og_card preset (1200×630 PNG) and returns it straight from a signed GET /v1/screenshot URL, with the right content type and no redirect. You can sign up and test it on 150 free credits a month.
Last tested 2026-10-18.
Related in this topic
- Generate OG Images From HTML: A Production Guide Generate Open Graph images from an HTML template: platform size limits, escaping, signed URLs without redirects, cache-safe expiry and a Next.js example.
- The State of OG Images: A 10,000-Site Crawl We crawled the top 10,000 home pages for og:image and Twitter card tags: adoption, sizes, formats, file weight and broken image URLs, with our method.
- Next.js OG Images: opengraph-image.tsx vs a Route Handler Dynamic OG images in Next.js 16: opengraph-image.tsx, the tags it emits, measured static vs per-request timings, fonts, and when a screenshot route wins.
- OG Image Size: 1200×630, Crops and File Limits The right OG image size is 1200×630 (1.91:1). Platform limits with sources, how many pixels each preview shape crops, and measured file sizes at 1x and 2x.
- Rails OG Images: meta-tags, an ERB Card and Signed URLs Dynamic Open Graph images in Rails 8: an ERB card route, the meta-tags gem, a signed og:image helper, Minitest tests and what allow_browser does to crawlers.
- Twitter Card Image Size: summary vs summary_large_image X card image sizes: 1200×600 (2:1) for summary_large_image, a square for summary. What 1200×630 and 1200×628 lose to the crop, and the tags that pick the card.