Guide

How to add Open Graph tags in Next.js, Astro, Hugo, and WordPress

Every framework has its own place for share tags, and each one makes a different mistake easy: a relative image path, a child page that wipes the parent's image, or two plugins printing competing tags. Here is the setup per stack, and the one test that tells you it worked.

The tags you are aiming for

Whatever generates them, the server HTML for each page should end up with this in <head>:

<meta property="og:title" content="Page title">
<meta property="og:description" content="One or two sentences.">
<meta property="og:url" content="https://example.com/blog/post">
<meta property="og:type" content="article">
<meta property="og:image" content="https://example.com/og/post.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="What the image shows">
<meta name="twitter:card" content="summary_large_image">

On a plain HTML site, paste that block and you are done. Note the absolute https:// URLs; see absolute OG image URL for why relative paths fail.

Next.js (App Router)

Export metadata from a server layout or page. Set metadataBase once in the root layout so relative url and image values come out absolute.

// app/layout.tsx
export const metadata = {
  metadataBase: new URL("https://example.com"),
  openGraph: { siteName: "Example", type: "website" },
  twitter: { card: "summary_large_image" },
};

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

Next.js (Pages Router)

Use next/head in the page, and fill it from getStaticProps or getServerSideProps so the values are in the first HTML response:

import Head from "next/head";

export default function Post({ post }) {
  const url = `https://example.com/blog/${post.slug}`;
  return (
    <>
      <Head>
        <meta property="og:title" content={post.title} key="og:title" />
        <meta property="og:description" content={post.summary} key="og:description" />
        <meta property="og:url" content={url} key="og:url" />
        <meta property="og:image" content={`https://example.com/og/${post.slug}.png`} key="og:image" />
        <meta name="twitter:card" content="summary_large_image" key="twitter:card" />
      </Head>
      {/* page */}
    </>
  );
}

The key props stop a default in _app and the page's tag from both rendering. Data fetched in useEffect arrives too late for crawlers; see JavaScript / SPA previews.

Astro

Set site in astro.config.mjs (for example site: "https://example.com"), then build absolute URLs from it in your layout:

---
// src/layouts/Base.astro
const { title, description, image = "/og.png" } = Astro.props;
const pageUrl = new URL(Astro.url.pathname, Astro.site);
const imageUrl = new URL(image, Astro.site);
---
<head>
  <meta property="og:title" content={title} />
  <meta property="og:description" content={description} />
  <meta property="og:url" content={pageUrl} />
  <meta property="og:image" content={imageUrl} />
  <meta name="twitter:card" content="summary_large_image" />
</head>

Without site, Astro.site is undefined and you are back to relative paths. Pass title, description, and image from each page or from content collection front matter.

Hugo

Hugo ships Open Graph and X card templates. Call them from your head partial:

{{ partial "opengraph.html" . }}
{{ partial "twitter_cards.html" . }}

Older Hugo versions use {{ template "_internal/opengraph.html" . }} and {{ template "_internal/twitter_cards.html" . }} instead. The image comes from the page's images front matter, then a page resource named like *feature*, *cover*, or *thumbnail*, then the first entry in params.images in your site config. Set baseURL to your real https:// domain, or every absolute URL points at the wrong host.

WordPress

An SEO plugin such as Yoast SEO or Rank Math prints Open Graph and X card tags for every post. Set a social image per post in the plugin's panel, and a site-wide default for pages without one.

Confirm the tags are in the server HTML

Your browser's inspector shows the DOM after JavaScript runs, which is not what crawlers read. Check the raw response instead:

curl -sL https://example.com/blog/post | grep -iE 'og:|twitter:'

You want exactly one of each tag, absolute https:// URLs, and the page's own values rather than the site default. If the output is empty but the tags show in the browser, they are being added client-side. If curl gets a challenge page, see crawler blocked.

Checklist before you ship

  1. Set your real domain once: metadataBase in Next.js, site in Astro, baseURL in Hugo, Site Address in WordPress.
  2. Give each page its own title, description, og:url, and image; check that a page-level override didn't drop the image.
  3. Use one generator of share tags, so there's one og:image in the HTML.
  4. Use a 1200×630 PNG or JPEG; see OG image size and OG image format.
  5. Run the curl check above on a real deployed URL, not localhost.
  6. Verify with CardScope to see the reconstructed card per client and what to fix, then refresh caches.

FAQ

How do I add Open Graph tags in the Next.js App Router?

Export a metadata object (or generateMetadata for dynamic routes) from a server layout or page, with an openGraph block and a twitter block. Set metadataBase in the root layout so relative image and url values become absolute. You can also drop an opengraph-image.png file into a route folder.

Why did my Next.js page lose its og:image?

Next.js merges metadata shallowly. If a page defines its own openGraph object, it replaces the parent layout's openGraph entirely, including images. Repeat the image in the page's block, or share a base object and spread it.

How do I add Open Graph tags in Hugo?

Call {{ partial "opengraph.html" . }} and {{ partial "twitter_cards.html" . }} in your head partial (older versions use the _internal/ template form). Set images in front matter or params.images in your site config, and make sure baseURL is your real https:// domain.

Why does my WordPress site have two sets of og tags?

More than one plugin or the theme is printing them, for example an SEO plugin plus a social or sharing plugin. Keep one source of Open Graph tags and turn the feature off in the others, then view the page source to confirm one og:title and one og:image.

What does a CardScope check cost?

Link-card previews and the fix checklist are Pro: $19 once via Stripe Checkout. No account. We parse HTML in memory and discard it — we do not store the page.

Check a URL on CardScope

Paste a deployed page. CardScope reads the tags crawlers see in the server HTML, fetches the og:image, and shows a reconstructed card for X, LinkedIn, Slack, Discord, and iMessage with a fix checklist. Previews and the fix checklist unlock with Pro ($19 once) via Stripe Checkout. No account. We parse HTML in memory and discard it — we do not store the page.

Try a URL