OG Cover
Tutorials

How to add Open Graph meta tags in Next.js

The Metadata API, metadataBase, per-route overrides, and the three mistakes that make a card render blank in production.

Rawand DevBuilder of OG Cover
· 3 min read
Share
The Open Graph card for this post, made with OG Cover

Next.js has a Metadata API that writes Open Graph tags for you, which means the usual failure is not a missing tag. It is a tag that renders with a relative URL, or on the wrong route, or only in development.

The minimum that works

In the App Router, export a metadata object from a layout or a page. Next renders it into <head> on the server, which is what matters: crawlers do not run your JavaScript, so a tag injected on the client is a tag nobody sees.

// app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
  title: {
    default: "Example",
    template: "%s · Example",
  },
  description: "What the site does, in one sentence.",
  openGraph: {
    siteName: "Example",
    locale: "en_US",
    type: "website",
  },
  twitter: { card: "summary_large_image" },
};

Two things here do most of the work.

metadataBase is what turns /og.png into https://example.com/og.png. Without it, Next emits the relative path, and a crawler resolving og:image without your page as a base gets nothing. This is the single most common cause of a card that works locally and breaks in production.

title.template appends the site name to every child page title, so no page has to repeat it.

Per-page metadata

A page exports its own object, which is merged over the parent.

// app/blog/page.tsx
export const metadata: Metadata = {
  title: "Blog",
  description: "Writing about Open Graph images.",
  alternates: { canonical: "/blog" },
  openGraph: {
    type: "website",
    url: "/blog",
    title: "Blog · Example",
    description: "Writing about Open Graph images.",
  },
};

Note that the merge is shallow. A page that sets openGraph replaces the parent's openGraph object rather than merging into it, so siteName and locale have to be repeated on any page that declares its own. This surprises people the first time a card loses its site name.

Dynamic routes

For a route with a parameter, export generateMetadata instead. It receives the same params promise the page does.

// app/blog/[slug]/page.tsx
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = getPost(slug);
  if (!post) return {};

  return {
    title: post.title,
    description: post.description,
    alternates: { canonical: `/blog/${post.slug}` },
    openGraph: {
      type: "article",
      url: `/blog/${post.slug}`,
      title: post.title,
      description: post.description,
      publishedTime: post.published,
      images: [{ url: post.cover, width: 1200, height: 630, alt: post.title }],
    },
  };
}

params is a promise in current versions. Awaiting it is not optional, and forgetting is a build error rather than a silent bug, which is the good outcome.

Pointing at a real image

Three ways, in increasing order of effort.

  1. A static file. Put og.png in /public and reference /og.png. With metadataBase set, Next makes it absolute. Fine for a whole site with one card.
  2. A file convention. Drop opengraph-image.png next to a route's page.tsx and Next wires the tags up itself, dimensions included. Good for a handful of hand-made cards.
  3. Generated per page. Render a card per post at build time. This is worth it once you have more than a few dozen pages, and not before.

Whichever you choose, the tag has to end up as an absolute https:// URL that loads with no cookies. Paste it into a private window and check.

The three mistakes

Relative image URLs. Covered above, and it is worth checking first every time. View source on the deployed page and confirm og:image starts with https://.

Metadata in a Client Component. metadata and generateMetadata only work in Server Components. In a file with "use client" at the top they are ignored silently. Move the export up into the nearest server layout or page.

Preview deployments with protection on. A protected preview URL returns a login page to the crawler, so both the page and the image fail. Test cards against production, or against a preview with protection disabled.

Verifying it worked

curl -s https://example.com/blog/my-post | grep -i 'og:'

If the tags are in that output, they are in the HTML the server sent, which is the only version a crawler sees. After that, run the URL through the Facebook Sharing Debugger and the LinkedIn Post Inspector, both of which re-crawl on submit and print what they found.

If the tags are correct and the preview is still stale, the problem is caching rather than markup. That is a different fix, covered in why your OG image is not updating.