OG Image for Single Page Apps: Fix Social Previews in React, Vue, Angular SPAs
Social previews broken in your SPA? Learn why client-side rendering kills OG tags and how to fix them with SSR, prerendering, or a metadata proxy — with code examples for React, Vue, and Angular.
Updated March 2026
Why SPAs Break OG Tags
Social media crawlers — Twitterbot, facebookexternalhit, LinkedInBot, Slackbot — do not execute JavaScript. When they fetch your Single Page App URL, they receive a nearly empty HTML shell with no <meta property="og:..."> tags. Your React/Vue/Angular code that updates the document title and meta tags runs only in a browser — never in the scraper.
The result: every page of your SPA shares the same link preview — usually the default homepage image, or worse, no image at all.
Solution 1: Use a Framework with SSR (Recommended)
The cleanest fix is to switch to a framework that renders HTML on the server, so meta tags are present in the initial response that crawlers receive.
- React: Migrate to Next.js — it handles SSR/SSG out of the box and has a first-class
generateMetadataAPI. - Vue: Migrate to Nuxt.js — it provides the
useSeoMeta()composable for per-route OG tags. - Angular: Enable Angular Universal (SSR) — Angular's
Metaservice then works server-side. - Svelte: Migrate to SvelteKit — use
<svelte:head>in page components with server load functions.
Solution 2: Static Pre-rendering Per Route
If SSR is too heavy a lift, pre-render static HTML snapshots for each route. The static HTML includes server-injected meta tags; the SPA hydrates afterward in the browser.
React (Vite / Create React App) with react-snap
npm install --save-dev react-snap
// package.json
"scripts": {
"build": "vite build",
"postbuild": "react-snap"
},
"reactSnap": {
"puppeteerArgs": ["--no-sandbox"]
}react-snap crawls your app after build and saves static HTML snapshots. Each snapshot will include whatever meta tags are in the DOM at the time of the snapshot — so use a library like react-helmet-async to set per-route OG tags.
import { Helmet } from "react-helmet-async";
function ProductPage({ product }) {
return (
<>
<Helmet>
<title>{product.name} — My Store</title>
<meta property="og:title" content={product.name} />
<meta property="og:description" content={product.description} />
<meta property="og:image" content={product.imageUrl} />
<meta property="og:url" content={`https://mystore.com/products/${product.slug}`} />
</Helmet>
{/* page content */}
</>
);
}Vue (Vite) with vite-ssg
npm install -D vite-ssg
// vite.config.ts
import { ViteSsg } from 'vite-ssg'
export default defineConfig({ plugins: [ViteSsg()] })Then use @vueuse/head or @unhead/vue to set per-route meta tags in your <script setup> blocks.
Solution 3: Metadata Proxy / Edge Middleware
This approach intercepts bot requests at the CDN/edge layer and serves a pre-rendered metadata page instead of the SPA shell. The real users still get the fast SPA experience.
How it works:
- Detect crawler user agents at the edge (Cloudflare Workers, Vercel Edge Middleware, Netlify Edge Functions).
- Fetch your API for the page's metadata (title, description, image URL).
- Return a minimal HTML document with OG tags injected.
- Regular browsers get the full SPA as normal.
// middleware.ts (Vercel Edge Middleware)
import { NextRequest, NextResponse } from "next/server";
const BOT_UA = /facebookexternalhit|twitterbot|linkedinbot|slackbot|discordbot|telegrambot/i;
export function middleware(req: NextRequest) {
if (BOT_UA.test(req.headers.get("user-agent") ?? "")) {
const url = req.nextUrl.pathname;
// Fetch metadata from your API
return NextResponse.rewrite(new URL(`/api/meta${url}`, req.url));
}
return NextResponse.next();
}Solution 4: Dynamic OG Images with a Serverless Function
If you can't do SSR but still want unique images per page, generate them on-demand. A serverless function reads URL query parameters and returns a dynamically rendered PNG.
See Dynamic OG Images Guide for how to build this with Vercel's @vercel/og or a Cloudflare Worker + satori.
React SPA: Full Working Example
Here's a complete setup for a Create React App SPA with pre-rendering using react-snap + react-helmet-async:
// 1. Install dependencies
npm install react-helmet-async
npm install --save-dev react-snap
// 2. Wrap your app
// index.tsx
import { HelmetProvider } from "react-helmet-async";
ReactDOM.render(
<HelmetProvider><App /></HelmetProvider>,
document.getElementById("root")
);
// 3. Set per-route meta
// pages/ProductPage.tsx
import { Helmet } from "react-helmet-async";
export default function ProductPage() {
const product = useProduct(); // your data hook
return (
<>
<Helmet>
<title>{product.name}</title>
<meta property="og:title" content={product.name} />
<meta property="og:description" content={product.tagline} />
<meta property="og:image" content={`https://mysite.com/og/${product.slug}.png`} />
<meta property="og:url" content={`https://mysite.com/products/${product.slug}`} />
<meta name="twitter:card" content="summary_large_image" />
</Helmet>
{/* render product */}
</>
);
}
// 4. package.json
{
"scripts": {
"build": "react-scripts build",
"postbuild": "react-snap"
},
"reactSnap": { "puppeteerArgs": ["--no-sandbox"] }
}Vue SPA: Full Working Example
// With @unhead/vue (recommended for Vue 3)
npm install @unhead/vue
// main.ts
import { createHead } from "@unhead/vue";
const app = createApp(App);
app.use(createHead());
// ProductPage.vue
<script setup>
import { useHead } from "@unhead/vue";
const product = useProduct();
useHead({
title: () => product.value?.name,
meta: [
{ property: "og:title", content: () => product.value?.name },
{ property: "og:description", content: () => product.value?.tagline },
{ property: "og:image", content: () => `https://mysite.com/og/${product.value?.slug}.png` },
{ name: "twitter:card", content: "summary_large_image" },
],
});
</script>Combine with vite-ssg for static pre-rendering.
Test Your Fix
After implementing one of the solutions above, verify the fix worked by checking your page with OGFixer. Paste your URL and confirm that OG tags are present in the parsed metadata and that the social card preview renders correctly.
Verify your SPA OG tags →
Paste any URL from your SPA into OGFixer to see what crawlers actually receive.
Check my SPA OG tags →