Social SEO

Open Graph Meta Tags: Social Media SEO Guide

By the SEOtest.app Editorial TeamUpdated June 17, 20267 min read

This is the "do it properly" guide — the one you reach for when four hand-written tags on a static page aren't enough and you need correct, dynamic Open Graph across a real codebase. If you just want the five-minute starter set, read the beginner guide to OG tags first and come back when you're generating pages programmatically.

We'll cover the full tag set, working code for Next.js, raw HTML, and WordPress, the per-page dynamic pattern, the one gotcha that silently breaks half of all single-page apps, and how to verify it all.

The full tag set, annotated

The minimum is four tags. A complete card uses these:

<meta property="og:title"        content="The Page Headline" />
<meta property="og:description"  content="One or two sentences, ~110–140 chars." />
<meta property="og:url"          content="https://example.com/page" />
<meta property="og:image"        content="https://example.com/og/page.jpg" />
<meta property="og:image:width"  content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt"    content="Plain-text description of the image" />
<meta property="og:type"         content="article" />
<meta property="og:site_name"    content="Example" />
<meta property="og:locale"       content="en_US" />

<meta name="twitter:card"        content="summary_large_image" />

The two lines people forget are og:image:width and og:image:height. Without them, Facebook and LinkedIn won't render the large card on their first scrape — they show a blank or small preview until they've fetched the image once, which is exactly when your launch post goes out. Declaring the dimensions lets them draw the big card immediately. For the exhaustive vocabulary (video tags, article:*, product:*), see the meta property og reference.

Next.js (App Router) — the Metadata API

Don't hand-write <meta> tags in Next.js. Use the Metadata API so the framework renders them into the server HTML where scrapers can read them.

Static, per-route:

// app/about/page.tsx
export const metadata = {
  title: "About Example",
  description: "Who we are and what we build.",
  openGraph: {
    title: "About Example",
    description: "Who we are and what we build.",
    url: "https://example.com/about",
    siteName: "Example",
    images: [{ url: "https://example.com/og/about.jpg", width: 1200, height: 630 }],
    type: "website",
  },
  twitter: { card: "summary_large_image" },
};

Dynamic, per-page (e.g. a blog post pulled from a CMS):

// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }) {
  const post = await getPost(params.slug);
  return {
    title: post.title,
    openGraph: {
      title: post.title,
      description: post.summary,
      url: `https://example.com/blog/${post.slug}`,
      images: [{ url: post.ogImage, width: 1200, height: 630 }],
      type: "article",
    },
    twitter: { card: "summary_large_image" },
  };
}

Because generateMetadata runs on the server, the tags land in the initial HTML — which is the whole point (see the gotcha below).

Raw HTML / static sites

If you template by hand or use a static generator (Eleventy, Hugo, Jekyll), inject the values per page. Conceptually:

<head>
  <title>{{ page.title }}</title>
  <meta property="og:title"       content="{{ page.ogTitle | default: page.title }}" />
  <meta property="og:description" content="{{ page.description }}" />
  <meta property="og:url"         content="{{ site.url }}{{ page.url }}" />
  <meta property="og:image"       content="{{ site.url }}{{ page.ogImage }}" />
  <meta property="og:type"        content="{{ page.ogType | default: 'website' }}" />
  <meta name="twitter:card"       content="summary_large_image" />
</head>

The key detail: prefix every relative path with the absolute site URL. og:image and og:url must be fully-qualified https:// URLs or scrapers can't resolve them.

WordPress

You almost never hand-edit header.php. Install Yoast SEO or Rank Math, open a post's editor, find the "Social" panel, and set the social title, description, and image per post. The plugin writes the og: and twitter: tags into the rendered <head> for you, with sensible site-wide defaults so untouched pages still get a card. If you've added OG tags manually and a plugin is active, you'll get duplicates — pick one source.

The gotcha that breaks SPAs: server-side vs client-side injection

Here is the failure that wastes the most debugging time. Social scrapers (Facebook, LinkedIn, Slack, etc.) do not run JavaScript. They fetch your page's raw HTML response and read whatever <meta> tags are in it. That's all.

So if you set Open Graph tags client-side — document.head manipulation, a vanilla react-helmet in a client-only render, or any tag injected after hydration — the scraper fetches the page before your JS runs, sees an empty or default <head>, and builds a generic card or none at all. The tags are technically "there" when you inspect the DOM in your browser, which is why this is so confusing: it works in your devtools and fails for every share.

The fix is to render OG tags server-side:

  • Next.js / Remix / Nuxt — use the framework's metadata API (above). Server-rendered by default.
  • Plain React SPA (Vite/CRA) — a client-only app can't fix this per-route. Use server-side rendering, prerendering at build time, or an edge function that injects per-route tags into the HTML response.
  • Quick test — view the page with curl -s https://yourpage | grep 'og:'. If your tags aren't in that raw output, no scraper will see them either.

Per-page dynamic images

A single shared OG image for the whole site is fine to start, but a per-page image lifts click-through noticeably. Two approaches: pre-render an image per page at build time, or generate one on the fly (Next.js ImageResponse / a serverless renderer) that stamps the post title onto a template. Either way, keep the output 1200×630 and compressed — the specifics live in OG image size and format best practices.

Validate — don't publish and hope

After deploy, run the page through our Open Graph Checker to confirm the exact tags in the live HTML, then the Social Preview tool to see the rendered card across platforms. Then re-scrape with each network's own debugger (Facebook Sharing Debugger, LinkedIn Post Inspector) to bust their cache, since they hold old previews for days. If the checker shows correct tags but the platform shows a stale card, it's purely a caching issue — force the re-scrape and you're done.

Wondering whether any of this moves your Google rankings? It doesn't, directly — the real (indirect) mechanism is laid out in does Open Graph affect SEO.

Frequently Asked Questions

How do I set different Open Graph tags for each page?

Generate them server-side per route. In Next.js use generateMetadata in the dynamic route; in WordPress let Yoast/Rank Math fill them from each post's fields; in static generators template the values per page. The critical part is that the tags render into the initial HTML, not after JavaScript runs.

Why do my OG tags work in the browser but not when shared?

Almost always because they're injected client-side. Scrapers don't execute JavaScript — they read the raw HTML response. Tags added by client-side JS appear in your devtools DOM but not in the response the scraper fetches. Render them server-side. Verify with curl -s URL | grep og:.

Do I need both Open Graph and Twitter Card tags?

Add Open Graph plus a single twitter:card="summary_large_image" line. X reads twitter: tags first and only partially falls back to og:, so that one line ensures a large image. For full Twitter control, see our Twitter Card guide.

Will duplicate og:image tags cause problems?

Yes — if a plugin and your theme both emit OG tags, platforms may pick the wrong one. Use a single source. In WordPress, that means either the SEO plugin or manual tags, never both.

How do I force a platform to refresh an old preview?

Use the network's debugger: Facebook Sharing Debugger and LinkedIn Post Inspector both have a "scrape again" action. As a fallback, append a throwaway query string (?v=2) to the shared URL so the platform treats it as a new resource.

Put this knowledge into action

Analyze your website with our free SEO tool and get instant recommendations.

Analyze Your Website