Guide
Link preview missing on a React or SPA site? Crawlers don’t run JavaScript
You set og:title and og:image, DevTools shows them in the head, and Slack still unfurls a bare URL. The usual cause on React, Vue, Angular, and other single-page apps: the tags are added by JavaScript after load. Link-preview crawlers read the first HTML response and leave. Whatever your bundle adds later is not there when they look.
CardScope fetches the URL from the edge the same way: a plain HTTP request, no browser, no script execution. If CardScope cannot find a tag, a crawler that does not render JavaScript cannot find it either. The shells it draws are rebuilt from tags, not a pixel-perfect copy of each client.
Who reads what
| Reader | What it sees |
|---|---|
| Your browser (DevTools Elements) | The DOM after every script ran, including head tags from a head manager |
| Slack, X, LinkedIn, Discord, Facebook, WhatsApp, Telegram, iMessage | The HTML the server returned. Treat them as not running your JavaScript |
| Google Search | Can render JavaScript for indexing — which is why SEO looks fine while unfurls do not |
Check it in 30 seconds
Look at the response, not the DOM. Either open view-source: on the URL, or run:
curl -sL https://example.com/your-page | grep -iE 'og:|twitter:'
No output means no Open Graph for crawlers, no matter what DevTools shows. If you see tags but they are the site-wide defaults on every page, per-page tags are being set in the browser only. Paste the URL into CardScope to see the same thing as a tag table with a fix checklist.
Symptoms that point here
- Bare URL everywhere — no card on Slack, LinkedIn, or X, but the page looks fine in a browser.
- Every link shows the same card — the home page title and image on every blog post or product page.
- Hash routes — URLs like
/#/pricing. The part after#is never sent to the server, so it cannot return per-page tags even if it wanted to. - Head managers only —
react-helmet,document.titleanddocument.headedits, or a client-onlyuseHeadwith no server rendering.
Fixes, from smallest to most complete
- Static defaults in
index.html— put a realog:title,og:description, absoluteog:image, andtwitter:cardin the shell HTML. Every page gets the same card, but it beats a bare URL. Ten minutes. - Prerender the routes people share — generate static HTML for the home page, pricing, and posts at build time, with their own tags baked in. Most static-site and SPA build tools have a prerender or static export option.
- Server rendering or static generation — frameworks that render on the server put the head in the response: Next.js (metadata API), Nuxt with SSR, SvelteKit, Remix, Astro, Angular SSR. Set tags in their server-side head API, not in a client-only effect.
- Rewrite the head at the edge — keep the SPA, and have a CDN worker inject per-route tags into
index.htmlbefore it is returned (for example with Cloudflare’sHTMLRewriter). Useful when the app cannot move to SSR yet.
Avoid serving different content to bots than to people beyond the head tags; keep the title and image honest to what the page shows. And check that bot protection is not answering crawlers with a challenge page — that HTML has no Open Graph either.
After the fix
Make sure the image URL is absolute https:// (see absolute OG image URL), the size is right (OG image size), and og:url agrees with the canonical (og:url vs canonical). Clients cache old cards, so a link shared before the fix can stay bare for a while — the clear Open Graph cache guide covers refreshing them.
Checklist before you ship
- Read the response, not the DOM —
curlorview-source:shows what crawlers see. - Ship defaults in the shell HTML so no route unfurls as a bare URL.
- Render per-page tags on the server (SSR, static generation, prerender, or an edge rewrite) for the pages people share.
- Use real paths, not
#routes, for anything that needs its own card. - Verify with CardScope — tag table, reconstructed shells, and checklist — then refresh caches on the platforms that already saw the old version.
FAQ
Do link-preview crawlers run JavaScript?
In practice, no. The bots behind Slack, X, LinkedIn, Discord, Facebook, WhatsApp, and iMessage fetch the URL and read the HTML in that first response. Tags that a script adds to the head after the page loads are not there when they look. Google Search can render JavaScript; link-preview crawlers should not be assumed to.
Why does DevTools show my og:image but Slack does not?
The Elements panel shows the DOM after your scripts ran. Crawlers see the server response. Compare with view-source: or curl: if the og: tags are missing there, they are missing for every unfurl. Slack-specific notes are in Slack vs Discord.
How do I fix Open Graph tags on a single-page app?
Put the tags in the HTML the server sends. Use server rendering or static generation in your framework, prerender the routes you share at build time, or rewrite the head at the edge per route. At minimum, ship sensible site-wide defaults in the static index.html.
Why do all my SPA links show the same card?
Usually because every route serves the same index.html with the same default tags, and per-page tags are only set in the browser. Hash routes make it worse: the part after # is never sent to the server, so the server cannot tell the pages apart. Use real paths and render per-route tags on the server.
What does a CardScope check cost?
Link-card previews and the fix checklist are Pro: $19 once via Stripe Checkout. No account. We parse HTML in memory and discard it — we do not store the page.
Check a URL on CardScope
Paste a deep link from your app, not just the home page. CardScope reads the raw server response like a crawler does, so you see which tags actually arrive, a reconstructed shell per client, and a fix checklist. Previews and the fix checklist unlock with Pro ($19 once) via Stripe Checkout. No account. We parse HTML in memory and discard it — we do not store the page.