Skip to main content
Documentation

React Open Graph Image Guide

React itself doesn't handle meta tags—it's a UI library, not a framework. But that doesn't mean setting up OG images is hard. Whether you're using Create React App, Vite, or any other setup, this guide covers everything you need to make your social shares look professional.

Important: If you're using Next.js, check the Next.js guide instead. It has built-in metadata handling that's much simpler than manual React approaches.

Understanding the Challenge

Here's the thing about React and OG images: social media crawlers don't execute JavaScript. When Twitter or LinkedIn fetches your page to get the OG image, they see your initial HTML—before React hydrates and renders your content.

This means if you're dynamically setting meta tags with React, those crawlers will never see them. They'll get whatever's in your static HTML file, which is usually nothing useful.

The solution? You need to either pre-render your meta tags at build time, or serve them from a server. Let's explore both approaches.

Method 1: Static OG Images (Simplest)

For single-page applications or sites with a handful of pages, the easiest approach is static OG images. Create them with og-image.org and hardcode the meta tags in your HTML.

Step 1: Create Your Images

Head to og-image.org, design your images, and download them. Put them in your public folder.

Step 2: Add Meta Tags to index.html

<!-- public/index.html (Create React App) -->
<!-- index.html (Vite) -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />

  <title>Your App Name</title>
  <meta name="description" content="Your app description here" />

  <!-- Open Graph -->
  <meta property="og:type" content="website" />
  <meta property="og:url" content="https://yoursite.com" />
  <meta property="og:title" content="Your App Name" />
  <meta property="og:description" content="Your app description here" />
  <meta property="og:image" content="https://yoursite.com/og-image.png" />
  <meta property="og:image:width" content="1200" />
  <meta property="og:image:height" content="630" />
  <meta property="og:image:alt" content="Your App Name preview" />

  <!-- Twitter -->
  <meta name="twitter:card" content="summary_large_image" />
  <meta name="twitter:url" content="https://yoursite.com" />
  <meta name="twitter:title" content="Your App Name" />
  <meta name="twitter:description" content="Your app description here" />
  <meta name="twitter:image" content="https://yoursite.com/og-image.png" />
</head>
<body>
  <div id="root"></div>
  <script type="module" src="/src/main.tsx"></script>
</body>
</html>

That's it. When crawlers hit your site, they see these meta tags immediately. No JavaScript execution required.

Method 2: React Helmet for Dynamic Titles

What if you want different titles and descriptions for different routes? React Helmet lets you manage document head from within your React components. But remember—this only works if the crawler executes JavaScript (most don't).

Installation

npm install react-helmet-async

Setup in App Root

// src/App.tsx
import { HelmetProvider } from 'react-helmet-async'
import { BrowserRouter } from 'react-router-dom'

function App() {
  return (
    <HelmetProvider>
      <BrowserRouter>
        {/* Your routes */}
      </BrowserRouter>
    </HelmetProvider>
  )
}

export default App

Using in Components

// src/pages/About.tsx
import { Helmet } from 'react-helmet-async'

export default function AboutPage() {
  return (
    <>
      <Helmet>
        <title>About Us | Your App Name</title>
        <meta name="description" content="Learn about our mission" />
        <meta property="og:title" content="About Us | Your App Name" />
        <meta property="og:description" content="Learn about our mission" />
        <meta property="og:image" content="https://yoursite.com/og-about.png" />
      </Helmet>

      <div>
        <h1>About Us</h1>
        {/* page content */}
      </div>
    </>
  )
}

Warning: React Helmet alone won't work for social media crawlers. You'll need server-side rendering or pre-rendering for crawlers to see your dynamic meta tags.

Method 3: Pre-Rendering with react-snap

Pre-rendering takes your SPA and generates static HTML files for each route. When crawlers visit, they get the pre-rendered HTML with all meta tags intact.

Installation

npm install --save-dev react-snap

Configuration

// package.json
{
  "scripts": {
    "build": "react-scripts build",
    "postbuild": "react-snap"
  },
  "reactSnap": {
    "puppeteerArgs": ["--no-sandbox"],
    "skipThirdPartyRequests": true,
    "include": ["/", "/about", "/contact", "/blog"]
  }
}

Now when you build, react-snap crawls your app and generates static HTML for each page. Combined with React Helmet, you get proper meta tags that crawlers can read.

Method 4: Vite with vite-plugin-ssg

Using Vite? There's a plugin that does static site generation at build time:

npm install vite-ssg
// src/main.ts
import { ViteSSG } from 'vite-ssg'
import App from './App'
import routes from './routes'

export const createApp = ViteSSG(App, { routes })

This generates static HTML with hydration, so crawlers see your full content including meta tags.

Method 5: Server-Side Rendering

For full control, you can add SSR to your React app. This means every request gets server-rendered HTML before React takes over on the client.

The complexity is higher, but you get maximum flexibility. Popular options:

  • Next.js - Full framework with built-in SSR (recommended if starting fresh)
  • Remix - Full framework with SSR and great data loading
  • Custom Express + React - Roll your own with renderToString

A Practical Example: Blog with React Router

Let's put it together. Here's a blog setup using React Router and React Helmet:

// src/pages/BlogPost.tsx
import { useParams } from 'react-router-dom'
import { Helmet } from 'react-helmet-async'
import { useBlogPost } from '../hooks/useBlogPost'

export default function BlogPost() {
  const { slug } = useParams()
  const { post, isLoading } = useBlogPost(slug)

  if (isLoading) return <div>Loading...</div>
  if (!post) return <div>Post not found</div>

  const ogImageUrl = `https://yoursite.com/og/blog/${slug}.png`

  return (
    <>
      <Helmet>
        <title>{post.title} | Your Blog</title>
        <meta name="description" content={post.excerpt} />

        {/* Open Graph */}
        <meta property="og:type" content="article" />
        <meta property="og:title" content={post.title} />
        <meta property="og:description" content={post.excerpt} />
        <meta property="og:image" content={ogImageUrl} />
        <meta property="og:image:width" content="1200" />
        <meta property="og:image:height" content="630" />
        <meta property="article:published_time" content={post.date} />
        <meta property="article:author" content={post.author} />

        {/* Twitter */}
        <meta name="twitter:card" content="summary_large_image" />
        <meta name="twitter:title" content={post.title} />
        <meta name="twitter:description" content={post.excerpt} />
        <meta name="twitter:image" content={ogImageUrl} />
      </Helmet>

      <article>
        <h1>{post.title}</h1>
        <time>{post.date}</time>
        {/* SECURITY: Always sanitize HTML content before rendering! */}
        {/* Use DOMPurify or similar: sanitize(post.content) */}
        <div dangerouslySetInnerHTML={{ __html: post.content }} />
      </article>
    </>
  )
}

Security Warning

The example above uses dangerouslySetInnerHTML. Never render untrusted HTML directly. Always sanitize content using libraries like DOMPurify to prevent XSS attacks.

Generating Blog OG Images

For each blog post, you'll need an OG image. You have two options:

Option A: Manual Creation

Use og-image.org to create an image for each post. Save them in public/og/blog/ with the post slug as the filename.

Option B: Build Script

Create a build script that generates OG images for all posts at build time:

// scripts/generate-og-images.mjs
import { Resvg } from '@resvg/resvg-js'
import satori from 'satori'
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
import { join } from 'path'

// Load your blog posts
import posts from '../src/data/posts.json'

// Load font
const inter = readFileSync('./fonts/Inter-Bold.ttf')

async function generateOgImage(post) {
  const svg = await satori(
    {
      type: 'div',
      props: {
        style: {
          height: '100%',
          width: '100%',
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          justifyContent: 'center',
          backgroundColor: '#0a0a0a',
          padding: 80,
        },
        children: [
          {
            type: 'h1',
            props: {
              style: { color: 'white', fontSize: 64 },
              children: post.title,
            },
          },
        ],
      },
    },
    {
      width: 1200,
      height: 630,
      fonts: [{ name: 'Inter', data: inter, weight: 700 }],
    }
  )

  const resvg = new Resvg(svg)
  const png = resvg.render().asPng()

  writeFileSync(`./public/og/blog/${post.slug}.png`, png)
}

async function main() {
  mkdirSync('./public/og/blog', { recursive: true })

  for (const post of posts) {
    await generateOgImage(post)
    console.log(`Generated: ${post.slug}.png`)
  }
}

main()

Which Approach Should You Use?

Your SituationRecommended Approach
Simple SPA, few pagesStatic images in index.html
Multiple routes, CRAReact Helmet + react-snap
Multiple routes, ViteReact Helmet + vite-ssg
Full dynamic contentSwitch to Next.js or Remix
Starting new projectUse Next.js from the start

Testing Your Implementation

Before shipping, verify your setup works:

  1. Build your app and serve the production build locally
  2. Disable JavaScript in your browser and visit each page
  3. View the page source—you should see all OG meta tags
  4. Use our validator tool to check how platforms see your pages

Bottom Line

React alone can't solve the OG image problem because crawlers don't run JavaScript. You need pre-rendering, SSR, or static HTML. For new projects, seriously consider Next.js—it handles all of this out of the box.