skip to content

A single-page app writes each route's Open Graph tags into the document head from JavaScript after the app mounts, yet every shared link still previews with the same site-wide title and image. Why does that happen, and what actually fixes it?

level: seniorimportance: must knowfreq 58%

answer

  1. the consumer never runs your bundle
  2. what bytes come back for this URL
  3. every route serves one identical shell
  4. curl the URL and grep the head
  5. fix the response, not the runtime

basics

~20 s

Link-preview scrapers read the HTML the server returns and generally do not execute JavaScript, so they only ever see the shell's fallback tags. The fix is to emit each route's Open Graph tags in the served HTML — server rendering or prerendering — not to inject them later.

solid answer

~50 s

Preview scrapers are not browsers. Slack, iMessage, Discord, LinkedIn and the social platforms fetch the URL, parse the returned HTML for `og:` and `twitter:` meta tags, and build a card from that; they generally do not run the page's scripts, so anything the app inserts into the head after mount is invisible to them. What they see is the index shell, which carries whatever generic tags the template shipped — hence the identical preview on every URL. The fix is structural, not a better injection library: the per-route tags have to be present in the HTML response for that URL. That means server-side rendering, or prerendering each route to static HTML at build time, or at minimum a server-side path that answers scraper requests with the correct head. Verify by fetching the URL with `curl` and grepping the response body for `og:title` — if it is not in that text, no amount of client-side work will put it on the card.

code

bash · 1 line
bash
curl -sL https://example.com/blog/post-42 | grep -i -E 'og:(title|image|url)'

go deeper

for a junior

Remember that link-preview crawlers read the HTML the server sends and do not run your JavaScript, so metadata added after mount never reaches a card.

for a middle

Explain why a client-rendered SPA serves one identical shell for every route, and name the fixes: server rendering, prerendering, or injecting the head server-side.

for a senior

An interviewer expects the diagnosis method — curl the production URL and grep for og: — plus a judgment on user-agent sniffing versus serving the same correct response to everyone.

for a principal

Own the architectural consequence: if shareability matters to the product, per-URL server-rendered metadata is a requirement that constrains the rendering strategy, not a detail to retrofit later.

## The scraper is not a browser When a URL is shared, the receiving platform sends a small crawler to fetch it. That crawler's job is narrow: retrieve the response, parse the head, extract a handful of metadata values, and possibly fetch the image. It has no rendering engine budget to spare, no interest in your router, and no reason to wait for a JavaScript bundle to download, parse, execute, hydrate, resolve a data request and finally call an API that appends `<meta>` elements. The major link-preview scrapers do not execute page scripts at all. So the question "why is my card wrong" reduces to a mechanical one: **what bytes does the server return for this URL?** For a classic client-rendered SPA the answer is the same index shell for every route — one `<title>`, one generic `og:image`, an empty root element and a script tag. Every share of every route produces the same card because every route serves the same document. ## Why the usual workarounds do not work - **A head-management library.** These operate on the live DOM after the framework mounts. They are correct and useful for the browser tab and for in-app behaviour; they change nothing about the response body. - **Injecting the tags earlier, in an inline script before the bundle.** Still script execution, still skipped. - **Putting the tags in the body instead of the head.** The head is where scrapers look, and moving markup around inside a document the scraper already parsed without running scripts changes nothing. - **Hash routes.** A fragment is never sent to the server, so `example.com/#/article/42` cannot be answered with per-article metadata at all. Any site that needs previews needs real path-based URLs. ## What actually fixes it The per-route metadata must be in the HTML response: 1. **Server-side rendering.** The server resolves the route, looks up the record, and writes the `og:` block into the head of the response. This is the general answer and the one that also fixes titles for other consumers. 2. **Prerendering / static generation.** At build time, render every known route to its own HTML file with its own head. Ideal for content sites with a bounded, known set of URLs; less so for user-generated pages that appear after the build. 3. **A metadata-only server layer.** Some teams keep the app client-rendered but put a small server in front that, for every request, injects the correct head into the shell before responding. Everyone gets correct tags, which is preferable to the older practice of detecting scraper user agents and serving them a different document — user-agent sniffing is fragile, easy to get wrong, and serving crawlers different content than users is a practice search engines treat with suspicion. ## Adjacent breakage from the same root cause The same "scraper sees only the shell" fact explains several sibling symptoms: previews show the app's loading placeholder as the description; `og:image` points at a default logo on every article; a share of a deep link opens correctly in the browser but unfurls as the homepage. It also explains why the bug survives QA — every human tester follows the link in a real browser, where the client-side tags do apply and everything looks right. ## How to verify, concretely ```bash curl -sL https://example.com/blog/post-42 | grep -i 'og:' ``` This is the whole test. It fetches exactly what an anonymous, script-free client receives. If the per-route `og:title` and `og:image` are in that output, the markup side is done and any remaining problem lives on the platform (caching, image reachability). If they are not, the card cannot be fixed from the client. Do the same check against production rather than a local dev server, because the difference between a dev-mode render and the deployed response is often the whole story. ## The interview point The answer an interviewer is listening for is not a library name. It is the recognition that link metadata is part of the **response**, not part of the running application, and that the class of consumers who need it — chat unfurlers, social scrapers, previewers embedded in mail clients — never becomes a browser. Once you have said that, the choice between full SSR, prerendering, and a thin injection layer is an engineering tradeoff you can walk through on the merits.

  • Someone proposes detecting scraper user agents and serving them a prerendered page while users keep the SPA. What is your reaction?
    It works, and it was standard practice for years, but it is the weaker option now. The agent list needs constant maintenance, a missed agent silently regresses previews, and serving crawlers markup that differs from what users get is a pattern search engines treat with suspicion. If you are already building the prerendered response, serve it to everyone.
  • How would you prevent this class of bug from coming back after it is fixed?
    Assert it in CI against a real built response, not a component test: request a couple of representative routes from the running server and check that the body contains that route's own og:title and og:image, and that they differ between routes. A snapshot of the rendered head per route catches both the regression to shell defaults and the accidental site-wide hardcode.
  • Does this affect Google the same way it affects Slack?
    Not identically — Google's crawler does render JavaScript, on its own schedule and with no guarantee of when. Link-preview scrapers do not render at all. So a client-injected tag may eventually be seen by search and will never be seen by an unfurler, which is why the honest answer is to put the metadata in the response and stop reasoning about who renders what.

saying these in an interview costs you the question

  • Blames the head-management library instead of the response
  • Suggests the scraper just needs more time to load
  • Moves the meta tags into the body to fix it
  • Tests only by opening the link in a browser
  • Thinks a hash route can carry per-page metadata

context