OGKit

Why SPA crawlers never see your Open Graph tags: SSR and prerender

  • open-graph
  • spa
  • ssr
  • debugging

You open the page, open Elements, and the meta tags are right there: og:title, og:image, a full description. You paste the same URL into Slack or X and get a naked link. Support asks whether the tags are "wrong." You are fairly sure they are not.

They can be correct in the browser and still absent to the crawler. That split is the SPA Open Graph trap.

Reproduce it in two requests

Stop arguing with DevTools first. Ask what actually crossed the wire.

# What a social crawler roughly gets: first HTML response, no JS
curl -sL -A "facebookexternalhit/1.1" https://example.com/app/product/42 | grep -iE 'og:|twitter:'

# Same URL without a bot UA (still no JS execution)
curl -sL https://example.com/app/product/42 | grep -iE 'og:|twitter:'

If those greps are empty, platforms will not invent tags from your hydrated DOM. View Source (not the live Elements tree) is the same check: source is the response body; Elements is the post-JS tree.

What you look at Who it represents Trust for share cards
Browser Elements / React DevTools Logged-in human after JS Low
Browser extension on the current tab Rendered DOM (user view) High for "what I see", low for crawlers
curl / View Source / public scanner Initial HTML High for crawlers
Platform debugger That platform's cache + fetch rules Authoritative for that platform

An extension that reads the already-rendered DOM is excellent for private hosts, login walls, and "did my editor write the right tags?" It is not a substitute for the crawler path. Those two views must be named separately or you will debug the wrong layer forever.

Why CSR meta fools developers

Client-side routers and useEffect (or helmet-style libraries that only run after mount) write tags into document.head after hydration. Humans never notice: the card preview extension, the tab title, even "inspect element" all look finished.

Most social crawlers do not execute (or only lightly execute) JavaScript. They fetch HTML, parse meta, cache, leave. Tags that appear only after JS might as well not exist. This is the same class of failure as relative og:image URLs or bot blocks — different cause, same blank card. The five-way blank-card split is in why your Open Graph card is blank.

A useful mental model:

  1. Wrong tags — fields present in raw HTML, but values/URLs/images are bad. Fix content and image plumbing.
  2. Unreachable tags — fields only exist after JS (or only for non-bot UAs). Fix delivery: SSR, prerender, or static HTML for share URLs.

Mixing those two burns days. You polish description copy while the crawler still receives an empty shell.

Framework levers (enough to choose a path)

You do not need a framework war. You need meta in the first HTML for every URL people share.

Approach What it means for OG Typical fit
SSR / streaming SSR Server emits head tags per request Next App Router metadata, Nuxt useSeoMeta, Remix loaders
SSG / build-time HTML Each known path has real meta in the file Marketing pages, docs, product catalog with known slugs
Prerender / render service A bot-facing layer returns HTML snapshots for crawlers Legacy CSR apps you cannot rewrite this quarter
Edge HTML rewrite Inject or swap meta at the CDN for bots Temporary bridge; keep a plan to own tags in app code
Pure CSR only Shell + client fill Fine for app chrome; not fine for public share URLs

Points that matter in practice:

  • Next.js: generateMetadata / static metadata must run on the server for that route. A client-only useEffect that sets document.title does not fix OG.
  • Nuxt / other meta modules: Prefer the framework's SSR-aware head API so tags land in the payload, not only in the browser.
  • Prerender.io-style services and "dynamic rendering": Can unblock crawlers without a full rewrite. They are infrastructure and cache policy, not a free pass — you still verify with curl and platform debuggers.
  • Build-time staticization of critical share paths is often enough when only a few templates matter for social.

If the product is a pure admin SPA behind login, maybe nobody shares deep links publicly — then this article is not your incident. If marketing, product pages, or user-generated public URLs are shared daily, pure CSR meta is a structural bug, not a content typo.

What a scanner should say when there is no SSR

Honest tooling separates layers. For a public URL scan that only sees first-byte HTML:

  • Missing share tags on a successful 200 → warn that tags may exist only after client JS (og-tags-require-javascript), not that the author "forgot og:title" with certainty.
  • Tags present in HTML but image fails → image engineering problem, not SPA delivery.
  • Tags only in the extension's DOM snapshot, absent from raw HTML → proven dual-HTML failure: crawlers will not see them (that path is error-grade evidence in OGKit when both sources are supplied).

What tools cannot do for you: invent a correct SSR architecture, migrate a client router, or guarantee every bot executes your bundle. Diagnosis stops at "crawler cannot see these tags." Architecture is the next conversation.

When it is no longer a tag fix

Escalate when the evidence is clear and the change is structural:

  • curl/View Source has no og:*, but the hydrated app does
  • dozens or hundreds of client-routed URLs must be shareable
  • a one-off static index.html shell cannot list every product/post path
  • the team needs a bridge (prerender) plus a target architecture (SSR/SSG), not another meta generator

That is consulting package territory for prerender / SSR when you want hands-on migration help — not a requirement to ship correct tags. Many teams fix a few critical templates themselves once the diagnosis is sharp.

If the problem is only cache (old good tags still showing), go to rescrape, not SSR. If the problem is only image bytes or redirects, go to image engineering. Wrong branch, wrong week.

Checklist: tags wrong vs crawler blind

  • curl (or View Source) shows the same og:* / twitter:* you see after load
  • Shared URLs are not "client-only" routes with an empty document head
  • Bot UA is not blocked while browsers work (related rule family)
  • You did not use an extension-only green check as proof for Facebook/LinkedIn/Slack
  • After fixing delivery, you rescrape platforms that already cached the shell

Try both views once

  1. Paste a public production URL into the homepage scan — crawler-shaped HTML, named rules, no local privileges.
  2. On a page that only exists after login or on localhost, use the extension on the current tab — that is the rendered DOM. If public scan is empty and the extension is full, you have the SPA gap in one screenshot pair.

OGKit will not silently rewrite your React tree. It will tell you which side of the table you are on: content wrong versus crawler never got a chance. That distinction is the whole job of this post.