React Open Graph Meta Tags: The Right Way to Add OG Tags
Add og:image, og:title, and og:description to React apps correctly — covering React Helmet, Vite SSG, Remix, React Router, and common mistakes that break social previews.
The core problem with React and OG tags
Social media crawlers (Twitter, LinkedIn, Facebook, Discord) do not execute JavaScript. In a standard client-side React app, the initial HTML response is a nearly empty shell — the <head> contains almost nothing until React hydrates in the browser. By then, the crawler has already given up.
This means client-side document.title mutations and useEffect-based meta tag injection will never work for social previews. The fix is to get meta tags into the HTML before it leaves the server.
Option 1: Next.js (recommended)
If you are using Next.js 13+ with the App Router, use the built-in Metadata API:
// app/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Page Title",
openGraph: {
title: "Page Title",
description: "Page description.",
images: [{ url: "https://yourdomain.com/og.jpg", width: 1200, height: 630 }],
},
twitter: {
card: "summary_large_image",
images: ["https://yourdomain.com/og.jpg"],
},
};This is server-rendered by default — the tags appear in the raw HTML response that crawlers receive.
Option 2: Remix
Remix has a built-in meta export for each route:
// routes/blog.$slug.tsx
import type { MetaFunction } from "@remix-run/node";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [
{ title: data.post.title },
{ property: "og:title", content: data.post.title },
{ property: "og:description", content: data.post.description },
{ property: "og:image", content: data.post.ogImage },
{ name: "twitter:card", content: "summary_large_image" },
];
};Option 3: React Helmet / react-helmet-async (for SSR setups)
If you are using Express + React with a custom SSR setup, React Helmet can write meta tags into the server response:
// Component
import { Helmet } from "react-helmet-async";
function PostPage({ post }) {
return (
<>
<Helmet>
<title>{post.title}</title>
<meta property="og:title" content={post.title} />
<meta property="og:description" content={post.description} />
<meta property="og:image" content={post.ogImage} />
<meta name="twitter:card" content="summary_large_image" />
</Helmet>
{/* page content */}
</>
);
}
// Server
import { renderToString } from "react-dom/server";
import { HelmetProvider } from "react-helmet-async";
const helmetContext = {};
const html = renderToString(
<HelmetProvider context={helmetContext}>
<App />
</HelmetProvider>
);
const { helmet } = helmetContext;
// Inject helmet.title.toString() + helmet.meta.toString() into <head>Important: this only works if you are doing full SSR. A plain create-react-app or Vite SPA with React Helmet does not solve the crawler problem — tags are still injected client-side.
Option 4: Vite with SSG (vite-plugin-ssg or Astro)
For static sites, use Vite SSG or Astro to pre-render pages at build time. This bakes the OG tags into static HTML files that crawlers can read without JavaScript.
// Astro example — src/pages/blog/[slug].astro
---
const { post } = Astro.props;
---
<html>
<head>
<title>{post.title}</title>
<meta property="og:title" content={post.title} />
<meta property="og:image" content={post.ogImage} />
<meta name="twitter:card" content="summary_large_image" />
</head>
<body>...</body>
</html>What definitely does NOT work
- useEffect / document.title mutation: these run after the JavaScript bundle executes in the browser — crawlers are gone by then.
- react-helmet in a SPA without SSR: Helmet writes to the DOM, not the server HTML. Crawlers see nothing.
- window.onload meta tag injection: same problem — browser-only.
Want to verify your React OG tags are rendering in the server response? Paste your production URL into OG Fixer — it fetches from the server side, just like social crawlers do, and shows you the live preview.