Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 5 min read

Next.js metadata openGraph not inherited: why my subpages lost og:site_name

If a page in the Next.js App Router sets its own openGraph, the openGraph object from your layout is gone for that page. Next.js merges metadata shallowly, so og:site_name, og:locale and any other field you only set in t

If a page in the Next.js App Router sets its own openGraph, the openGraph object from your layout is gone for that page. Next.js merges metadata shallowly, so og:site_name, og:locale and any other field you only set in the layout disappear. The fix I use is one helper that builds the full openGraph and twitter objects for every subpage, plus a build check that fails when a tag is missing.

I found this on my own site, mohamedgado.com. /now, /projects and all 55 case studies were shipping without og:site_name, og:locale, twitter:site and twitter:creator. The root layout set all four. Nothing in the browser looked wrong, and nothing errored.

Does Next.js merge openGraph metadata from the layout?

No, not field by field. The Next.js 16 docs for generateMetadata say that metadata objects exported from several segments of one route are shallowly merged, and that duplicate keys are replaced by the segment closest to the page. They also say it directly for nested fields: openGraph defined in an earlier segment is overwritten by the last segment that defines it.

The docs give the example. The layout sets openGraph.title and openGraph.description. The blog page sets only openGraph.title. The output has the page's og:title and no og:description, because the page's whole openGraph object replaced the layout's.

The same page does inherit when it does not set openGraph at all. A subpage with only a title still gets every Open Graph tag from the layout. The trouble starts the moment a page adds openGraph to change one field, such as the title or the image. That is the normal thing to do on a case study or a blog post, so it hits most real sites.

What did the broken setup look like?

My root layout carried the full set:

// app/layout.tsx
export const metadata: Metadata = {
  openGraph: {
    locale: siteConfig.openGraph.locale,
    siteName: siteConfig.name,
    url: siteConfig.links.websiteUrl,
    type: 'website',
    title: `${siteConfig.name} β€” ${siteConfig.tagline}`,
    description: siteConfig.description,
  },
  twitter: {
    card: 'summary_large_image',
    creator: siteConfig.twitterTag,
    site: siteConfig.twitterTag,
  },
}

A subpage then set what it needed, which looks harmless. This is a simplified version of what the case-study page did before the helper:

// app/projects/[slug]/page.tsx (before)
return {
  title: project.title,
  openGraph: {
    title: `${project.title} | Mohamed Gado`,
    description,
    url: `/projects/${slug}`,
    images: [ogImage],
  },
  twitter: {
    card: 'summary_large_image',
    title: `${project.title} | Mohamed Gado`,
    description,
  },
}

This page's openGraph has no siteName and no locale, so neither tag is rendered. Its twitter has no site or creator, so those are gone too. The layout's values were replaced, not combined.

How do I check the rendered tags?

Read the HTML the server sends, not the code. A dev tool that shows the metadata object tells you what you wrote, and the question is what crawlers receive. I curl the page and grep for the tag:

curl -s https://www.mohamedgado.com/projects/masareef | grep -o '<meta[^>]*og:site_name[^>]*>'
curl -s https://www.mohamedgado.com/projects/masareef | grep -oE '<meta[^>]*(og:locale|twitter:site|twitter:creator)[^>]*>'

An empty result means the tag is missing. I ran this against production before writing any code, because I wanted to know the gap was real on every subpage and not something I had inferred from reading the merge docs. It was.

What is the fix for Next.js openGraph not inheriting?

The docs suggest pulling shared fields into a variable and spreading it into each page's openGraph. That works, but it leaves it to every page author to remember the spread. I had 55 case studies built from one template plus a few static pages, so I put the whole thing in a function in lib/social-meta.ts:

import type { Metadata } from 'next'
import { siteConfig } from '@/config/site'

type SocialMetaInput = {
  title: string
  description: string
  /** Root-relative path, resolved against the layout's `metadataBase`. */
  path: string
  images: NonNullable<Metadata['openGraph']>['images']
  /** Case studies are articles; set this and `og:type` becomes `article`. */
  article?: { modifiedTime?: string }
}

export function buildSocialMeta({
  title,
  description,
  path,
  images,
  article,
}: SocialMetaInput): Pick<Metadata, 'openGraph' | 'twitter'> {
  const shared = {
    siteName: siteConfig.name,
    locale: siteConfig.openGraph.locale,
    title,
    description,
    url: path,
    images,
  }
  const twitter: Metadata['twitter'] = {
    card: 'summary_large_image',
    site: siteConfig.twitterTag,
    creator: siteConfig.twitterTag,
    title,
    description,
  }
  if (!article) {
    return { openGraph: { ...shared, type: 'website' }, twitter }
  }
  return {
    openGraph: {
      ...shared,
      type: 'article',
      authors: [siteConfig.links.websiteUrl],
      ...(article.modifiedTime ? { modifiedTime: article.modifiedTime } : {}),
    },
    twitter,
  }
}

A page spreads the result into its metadata:

// app/projects/[slug]/page.tsx (after)
return {
  title: project.title,
  alternates: { canonical: `/projects/${slug}` },
  ...buildSocialMeta({
    title: `${project.title} | Mohamed Gado`,
    description,
    path: `/projects/${slug}`,
    images: [ogImage],
  }),
}

Because buildSocialMeta returns both objects complete, the shallow merge no longer matters: the page's openGraph replaces the layout's, and the replacement already contains everything the layout had. The site name, locale and handles come from siteConfig, the same source the layout reads, so they cannot drift apart.

The helper also gave me a single place to add article fields. Case studies now pass article, which sets og:type to article and adds article:modified_time and article:author.

One detail in the helper: I leave twitter.images unset on purpose. My code comment says Next falls back to the openGraph images, with alt text and dimensions included, so the image is declared once.

How do I stop it coming back?

A convention in a README does not survive the next new page. I added a check to the postbuild step. scripts/check-index-parity.ts reads every prerendered page and asserts that it carries og:site_name, og:locale and both Twitter handles. Before the fix it reported 57 failures. After, 0.

The rule written down in my lessons file is short: a page that sets openGraph or twitter goes through buildSocialMeta, and the build fails otherwise. Because the check runs on built HTML, it also catches pages I did not think of, such as a new route someone adds later and forgets to wire up.

What else should I check on pages that override metadata?

Other nested fields behave the same way. The docs name robots next to openGraph as a field that is overwritten wholesale. If a layout sets robots: { index: true, follow: true, googleBot: {...} } and one page sets robots: { index: false }, the page does not keep the googleBot block. Anything that is an object in the Metadata type is a candidate: alternates, icons, twitter, appLinks and so on.

For each one, ask whether the layout sets it and whether any page sets it again. If both are true, the page's version has to be complete. A helper function, a spread of a shared object, or a build-time check on the output are all workable. I picked the helper and the check together, since the helper prevents the mistake and the check proves it.

The change was small, and nothing on the site looked different afterwards. What changed was the HTML that link previews and search engines read for 57 pages. If you set openGraph on any page below your root layout, curl one of those pages today and look for og:site_name.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.