Skip to content
Lovable Field Guide
Start here

Why your Lovable link preview is broken, and how to fix it

Lovable link preview not working? Unfurlers parse raw HTML, and in practice none of them run your JS. The mechanism, the fix per stack, and how to re-scrape.

12 min read

Your Open Graph tags are almost certainly correct; they just arrive too late for the unfurler. Link unfurlers parse the raw bytes your server returned, and in practice none of them run your JavaScript: Slack’s own docs describe fetching only part of the page with HTTP Range headers, which settles it for Slack; for Facebook and LinkedIn it is an inference their docs never contradict. Encited has a shorter version of this fix if you just need the steps. Either way, a tag that React adds after the page boots does not exist as far as they are concerned. The fix is to move the tags into the HTTP response, and which move you make depends on whether your project is on Lovable’s TanStack Start stack, the older React + Vite stack, or an export you host yourself.

The short version. Run curl -A 'facebookexternalhit/1.1' https://yoursite.com/blog/your-newest-post (a page with dynamically loaded content (CMS posts, product pages, directory listings: anything that isn’t written into the page’s source code)) and look at the <head> in the output. If your og:title is missing, or identical on every route, that is almost certainly the bug, with one exception on the older Vite stack that is covered below. Everything after that is how to fix it for your stack and then convince the platforms to look again.

Why the tag is correct in DevTools and missing on Slack

The sequence matters more than the library. Here is what happens on a client-rendered React app, in order:

  1. Your host returns index.html. Its <head> contains whatever the build baked in: one static set of tags, identical for every route.
  2. The unfurler parses that byte stream, extracts title, og:* and twitter:*, and it is done. It does not wait, and in most cases it does not even download your bundle.
  3. Later, in a real browser, the bundle downloads, React mounts, and react-helmet imperatively mutates document.head.

Step 3 is what you see in DevTools. Step 2 is what the unfurler saw, and the two are separated by a response that was already closed. That is why “it looks right when I inspect the page” and “the preview is wrong” are both true at once.

Swapping react-helmet for react-helmet-async changes nothing, because the limitation is structural rather than a bug in either package. The proof runs the other way too: curl the Docusaurus-built create-react-app.dev and the raw HTML carries helmet’s own og:image tag, because Docusaurus server-renders that output at build time. Same library, opposite result, purely because the tags were in the response body.

This is the same root cause behind every route sharing one title tag, and it sits underneath most of the Lovable SEO problem generally.

What each unfurler actually does

Vendor documentation is thinner here than you would hope, so it is worth separating what is stated from what is inferred.

Unfurler What its own docs say Runs JS?
Slackbot-LinkExpanding Fetches “as little of the page as it can” using HTTP Range headers, to pull oEmbed, Twitter Card and Open Graph tags. Previews cached roughly 30 minutes. No: a partial byte-range fetch cannot boot a JS runtime
facebookexternalhit/1.1 Open Graph properties must appear “before the first 1 MB” of the document or they are cut off. Not stated. A byte-offset cutoff is the signature of a streaming HTML parser, not a browser
LinkedInBot Requires og:title, og:description, og:image, og:url. Image minimum 1200x627, about 1.91:1, max 5 MB. Not stated anywhere in LinkedIn’s help docs
Twitterbot (X) Could not be verified X’s card troubleshooting docs returned HTTP 402 during research.

Slack’s page is the strongest citation in the set, because it describes the mechanism rather than the outcome: a Range request that deliberately fetches a fraction of your HTML cannot download a bundle and execute it. The others are inferred from consistent behavior; none of those platforms documents it.

The same non-rendering behavior extends past social. Vercel and MERJ measured in December 2024 that none of the major AI crawlers (GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, Meta-ExternalAgent, PerplexityBot, Bytespider) execute JavaScript, with Applebot the documented exception. Googlebot does render, which is why the honest framing is never “Google can’t see your React app.” Google is fine. Everything else is the problem.

Diagnose it in 60 seconds

Before changing anything, confirm the failure. Fetch your own pages with a crawler user agent and look at what comes back:

for route in / /pricing /blog/hello-world; do
  echo "=== $route ==="
  curl -sL -A 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)' \
    "https://yoursite.com$route" \
    | grep -oiE '<title>[^<]*</title>|<meta[^>]*(og:|twitter:)[^>]*>'
done

Three outcomes, three different problems:

  • Nothing matches. No metadata in the response at all. Your tags are client-only.
  • Identical output for every route. The static index.html tags are being served to everyone. This is the most common result.
  • Correct per-route tags, broken preview anyway. Your markup is fine; skip to the image section and then the re-scrape section.

One caveat on the method before you act on the output. On Lovable’s React + Vite stack, an empty or identical result tells you nothing about what Google or Slack received. Lovable’s docs say pre-rendered HTML goes only to crawlers it verifies, and that can’t be verified from outside. On that stack, use the Facebook Sharing Debugger or the LinkedIn Post Inspector instead, or Encited’s free OG image previewer for a quick look across platforms (these run Facebook’s and LinkedIn’s own crawlers, so they show what those platforms received). On TanStack Start there is no such gate, so curl gets the same bytes the unfurler is served, with the caveat above that Slack range-fetches only part of them and Meta stops parsing after the first 1 MB.

A fourth result is worth naming. If you get back a title like Just a moment..., you are looking at a bot-challenge interstitial: we hit exactly that during research, fetching a Cloudflare-fronted production site with a Facebook user agent. A challenge page returns 200 with its own metadata, so the preview renders fine; it just renders the challenge. Allowlist the unfurler user agents at your edge before debugging anything else.

Which Lovable stack are you on?

Lovable changed its default stack on 13 May 2026, from React + Vite to TanStack Start with SSR on Cloudflare Workers. Projects built before that date were not migrated and still run as before. Lovable’s own docs contradict each other on this (the deployment and hosting page and llms.txt still describe projects as Vite + React) so check the code rather than a doc page:

grep -E '"(@tanstack/react-start|@tanstack/react-router|react-router-dom)"' package.json

@tanstack/react-start means SSR is on by default: the server runs your React tree and returns rendered HTML. That is enough for head tags you declare in a route, which is most of what link previews need, but “SSR is enabled” and “everything on this page is in the HTML” are two different facts, and the next section is about the gap between them.

react-router-dom alone means the older client-rendered stack, where Lovable applies on-request pre-rendering to deployed public URLs but serves it only to verified crawlers: Google, Bing, social bots and AI engines. Humans still get the SPA, which is why generic audit tools report the site as empty while previews may work fine.

Fix 1: TanStack Start (Lovable’s default since May 2026)

Set the tags in the route definition so they are generated server-side, in the response:

// src/routes/pricing.tsx
// `head` runs on the server for this route, so these tags are in the
// response body before it is sent. That is the entire point.
import { createFileRoute } from '@tanstack/react-router'

const PAGE_URL = 'https://acme.com/pricing'
const OG_IMAGE = 'https://acme.com/og/pricing.png'
const DESC = 'Simple per-seat pricing. No setup fees.'

export const Route = createFileRoute('/pricing')({
  head: () => ({
    meta: [
      { title: 'Pricing | Acme' },
      { name: 'description', content: DESC },
      { property: 'og:type', content: 'website' },
      { property: 'og:site_name', content: 'Acme' },
      { property: 'og:url', content: PAGE_URL },
      { property: 'og:title', content: 'Pricing | Acme' },
      { property: 'og:description', content: DESC },
      { property: 'og:image', content: OG_IMAGE },
      { property: 'og:image:width', content: '1200' },
      { property: 'og:image:height', content: '630' },
      { name: 'twitter:card', content: 'summary_large_image' },
      { name: 'twitter:title', content: 'Pricing | Acme' },
      { name: 'twitter:description', content: DESC },
      { name: 'twitter:image', content: OG_IMAGE },
    ],
    links: [{ rel: 'canonical', href: PAGE_URL }],
  }),
  component: Pricing,
})

That example needs no data, which is why it works. The trap is the page whose title or image comes from a record: a blog post, a product, a profile.

SSR only includes data that a route loader fetched. React’s docs are explicit that effects “only run on the client. They don’t run during server rendering,” and TanStack Query’s SSR guide says queries you don’t prefetch “wont be server rendered, instead they will be fetched on the client after the application is interactive.” So a route can be fully server-rendered and still carry none of its dynamically loaded content in the response: the server faithfully renders the loading branch. What decides it is where the fetch lives: a route loader or a createServerFn the loader awaits puts the data in the HTML, while a useEffect or an un-prefetched useQuery inside the component does not. Lovable’s own description of the new stack is conditioned on exactly that: the server “runs the React tree, executes the loaders, and streams the result.”

For link previews this bites less often than it does for indexing, because head is route configuration and usually needs no fetch at all. But when the title or og:image is derived from a record fetched in a hook, head has nothing to read at render time and the unfurler gets whatever fallback the route or root layout left behind, which is how every post on a blog ends up sharing one generic card on a server-rendered site. Load the record in the loader and read it in head instead:

// src/routes/blog.$slug.tsx
// loaderData is resolved on the server before the HTML is streamed,
// so head() can read it. A useEffect fetch here would be too late.
export const Route = createFileRoute('/blog/$slug')({
  loader: ({ params }) => fetchPost(params.slug),
  // `head` is also called before the loader has resolved (pending state,
  // error boundary), so loaderData can be undefined. Guard it.
  head: ({ loaderData }) => ({
    meta: loaderData
      ? [
          { title: `${loaderData.title} | Acme` },
          { property: 'og:title', content: loaderData.title },
          { property: 'og:description', content: loaderData.excerpt },
          { property: 'og:image', content: loaderData.ogImage },
        ]
      : [{ title: 'Acme' }],
  }),
  component: Post,
})

The curl loop from the diagnose section is how you tell which case you are in, so run it against a page with dynamically loaded content, like /blog/your-newest-post rather than the homepage. SSR gets you the shell plus server-injected data, and anything the page loads later is missing.

Fix 2: React + Vite on Lovable hosting

Per-route Open Graph is supported here: Lovable’s SEO docs state og:title, og:description and og:image can be set per route, and its SEO review flags duplicate titles, placeholder text and weak descriptions. Use it rather than hand-editing index.html, because the pre-renderer is what actually answers the social bots.

Then check two things people routinely miss.

Republish. Publishing is a snapshot. Changes made after you publish do not reach the live site until you publish again, and the editor preview is not the live site. Verify against your real domain, never the preview URL.

Keep one sane sitewide default in index.html. Pre-rendering fires for verified crawlers; anything not on that list gets the raw shell, so the static tags are your fallback: a correct og:site_name, a real absolute og:image, a generic-but-accurate title. What you must avoid is relying on those static tags for per-page accuracy, or shipping two competing sets of the same tag. Check the per-page result in the Sharing Debugger. On this stack curl only sees the fallback tags.

Fix 3: code you exported and host yourself

This is where things quietly get worse, because Lovable’s pre-rendering is part of Lovable’s hosting. Export to GitHub, deploy to Vercel, Cloudflare Pages or Netlify, and nothing serves crawlers HTML any more. The preview that worked on *.lovable.app breaks on the custom deployment, which looks like a DNS or domain problem when it is a rendering one.

Four real options, cheapest first:

  1. Stamp per-route <head> into static files at build time. For link previews specifically this is enough: unfurlers only read <head>, so an empty <div id="root"> in the body costs you nothing here. It does not help crawlers that need body content.
  2. Adopt a framework rendering mode. TanStack Start’s static prerendering, or React Router’s prerender() in framework mode, both produce real HTML per route at build time.
  3. Edge middleware that serves rendered HTML to bots. Encited runs this as a hosted service; you can also hand-roll a Cloudflare Worker in front of your Pages deployment. Google calls dynamic rendering “a workaround and not a recommended solution” because of the complexity involved, and its spam policies don’t mention it. Bing explicitly recommends it.
  4. Full SSR. Correct for everything, and the largest change.

A minimal version of option 1, run after vite build:

// scripts/emit-route-html.mjs
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { dirname, join } from 'node:path'

const SITE = 'https://acme.com'
const routes = [
  { path: '/', title: 'Acme | Ship faster', desc: 'Build and deploy in minutes.', img: '/og/home.png' },
  { path: '/pricing', title: 'Pricing | Acme', desc: 'Simple per-seat pricing.', img: '/og/pricing.png' },
]

const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
const shell = await readFile('dist/index.html', 'utf8')

for (const r of routes) {
  const url = new URL(r.path, SITE).href
  const img = new URL(r.img, SITE).href
  const head = `
    <title>${esc(r.title)}</title>
    <meta name="description" content="${esc(r.desc)}">
    <link rel="canonical" href="${url}">
    <meta property="og:type" content="website">
    <meta property="og:url" content="${url}">
    <meta property="og:title" content="${esc(r.title)}">
    <meta property="og:description" content="${esc(r.desc)}">
    <meta property="og:image" content="${img}">
    <meta property="og:image:width" content="1200">
    <meta property="og:image:height" content="630">
    <meta name="twitter:card" content="summary_large_image">
    <meta name="twitter:image" content="${img}">`

  const html = shell
    .replace(/<title>[\s\S]*?<\/title>/i, '')
    .replace(/<link\s+rel="canonical"[^>]*>/i, '')
    .replace(/<meta[^>]*(property="og:|name="twitter:)[^>]*>/gi, '')
    .replace('</head>', `${head}\n  </head>`)

  const out = r.path === '/' ? 'dist/index.html' : join('dist', r.path, 'index.html')
  await mkdir(dirname(out), { recursive: true })
  await writeFile(out, html)
}

Note the three .replace() calls that strip what the shell already carries: the baked-in title, the canonical, and any og:/twitter: meta. That last one matters if you followed Fix 2 and kept a sitewide og:image and og:site_name in index.html: inject without stripping and every route ships two competing sets of the same tag, which is its own class of preview bug, since parsers differ on whether first or last wins.

Getting the og:image itself right

Correct tags with a bad image is the second-most-common version of this bug. The rules:

  • Absolute https:// URL, always. A relative /og/home.png resolves against whichever hostname served the document, so it breaks the moment the same HTML goes out from both yourapp.lovable.app and your custom domain, and not every parser resolves relative URLs at all.
  • 1200x630. That clears LinkedIn’s documented 1200x627 minimum and its roughly 1.91:1 ratio, and it is what every platform’s large-card layout expects.
  • Declare og:image:width and og:image:height. Two lines, and they let a consumer lay out the card from the markup instead of waiting on the image download.
  • Keep the file small. LinkedIn’s documented ceiling is 5 MB, but aim far lower: a few hundred kilobytes. Lovable-specific issue catalogs report users hitting og:image could not be fetched and Scrape failed due to timeout, both of which point at a slow or heavy image rather than the markup.
  • Serve it as a real static file. An image generated by an edge function that cold-starts, or one behind any bot challenge, will time out on the unfurler’s first attempt and cache as a failure.
  • Verify the image URL on its own. curl -sI https://acme.com/og/pricing.png should give you 200 and an image/* content type. A 302 to a login page or an HTML error body is a common cause of a preview that shows text but no picture.

Two situations make the head tags in your repo differ from the ones a bot receives. One is exporting to GitHub and deploying on Vercel, Cloudflare Pages or Netlify, where Lovable’s crawler pre-rendering doesn’t follow you. The other is a title or og:image built from a record fetched in a component hook, which lands in the DOM after the response has closed. The fix is to move that data into the route’s loader and head, or to pre-render the page for bots at the edge: Encited does the latter.

Force a re-scrape so you can actually see the fix

Every platform caches what it scraped, keyed by URL, which is why the fix looks like it failed. Clear each one deliberately:

  • Facebook / Instagram / Threads: the Sharing Debugger at developers.facebook.com/tools/debug/. Paste the URL, re-run the scrape, and read the parsed properties it reports. It shows exactly which tags it found, which makes it the best debugger of the set even for non-Meta problems.
  • LinkedIn: the Post Inspector at linkedin.com/post-inspector. Inspecting a URL re-fetches it and refreshes the stored preview.
  • Slack: no public debugger. Slack’s docs put the preview cache at roughly 30 minutes, so either wait it out or share a URL with a query string appended.
  • X: the Card Validator was retired, so there is no supported way to force a refresh. We could not verify X’s current crawler behavior from first-party docs; treat any confident claim about it, including ours, with suspicion.
  • Everything else (Discord, WhatsApp, Telegram, iMessage): no debuggers exist. Use the cache-buster.

The cache-buster is the universal escape hatch: append ?v=2 and share that. A different URL is a different cache key, so you get a fresh scrape with no waiting. Test with it, share the clean URL once the fix is confirmed, and make sure the parameterized variant still emits a self-referencing canonical pointing at the clean path.

What to do next

  1. Run the curl loop above against three real routes, at least one of them a page with dynamically loaded content, and save the output. That is your before state.
  2. Identify your stack with the grep on package.json, then apply the matching fix.
  3. Republish, if you are on Lovable hosting. The live site is a snapshot and will not reflect editor changes until you do.
  4. Re-check, by stack. On TanStack Start or a self-hosted export, re-run the same curl loop and diff it against the before state: you want exactly one og:title per route. On React + Vite on Lovable hosting the loop only ever shows you the static index.html fallback tags, so treat that diff as a duplicate-tag check and let step 5 tell you whether the per-route tags landed.
  5. Re-scrape in the Facebook Sharing Debugger and the LinkedIn Post Inspector (these run Facebook’s and LinkedIn’s own crawlers, so they show what those two platforms received) then share a ?v=2 variant into a Slack channel to check the one platform with no debugger.

If the raw HTML is right and the previews are still wrong, the problem has moved to the image or to a bot challenge at your edge, not to your tags. Fetch the og:image URL directly and check the status code before you touch the markup again.

Found something wrong or out of date? Lovable changes fast and we'd rather fix a guide than let it rot.

Disclosure: the team behind this guide also builds Encited, mentioned above.

Frequently asked questions

Why does the right title show in DevTools but the wrong one on Slack?
DevTools shows you the live DOM after JavaScript has run. An unfurler reads the raw HTTP response body and stops there. Tags injected by react-helmet, react-helmet-async or a useEffect are added to document.head after that response is already closed, so the unfurler never sees them.
Does switching from react-helmet to react-helmet-async fix Open Graph tags?
No. Both libraries mutate the DOM in the browser, so both are invisible to a crawler that does not execute JavaScript. react-helmet works fine when its output is server-rendered and baked into the response: the library was never the problem, the timing is.
I'm on Lovable's TanStack Start stack with SSR. Why do all my blog posts still share one preview card?
Server rendering only runs data fetches that live in a route loader. If a post's title and og:image come from a record loaded in a useEffect or an un-prefetched useQuery, the head tags are generated before that record exists, so every post falls back to the same route-level or root-level tags. Fetch the record in the route's loader instead and read it in head, which resolves on the server before the HTML is sent.
Does og:image have to be an absolute URL?
Yes, use a full https:// URL. A relative path like /og.png resolves differently depending on which hostname served the page, and not every unfurler resolves relative URLs at all. Point og:image at one absolute URL and add og:image:width and og:image:height next to it.
Why does the preview still show the old image after I fixed the tags?
Every platform caches what it scraped, keyed by URL. Slack's documentation says previews are cached for roughly 30 minutes. Facebook and LinkedIn cache too; the reliable way to refresh either is to re-run the Sharing Debugger or Post Inspector against the URL. Appending a query string such as ?v=2 creates a fresh cache key you can test against immediately.
Will link previews work from a Lovable preview or workspace URL?
Treat them as unreliable for sharing. Lovable's docs state that only publicly published projects are indexable, and that private projects and branded workspace URLs are not: share the published public URL or your custom domain instead.

Read next