Next.js: a page that sets openGraph loses the layout's og:image

Christian Drexler

During a check of this site I looked at what each page offers when its link is shared. The home page had a share image. The service pages had a title and nothing else, the kind of link that shows up in a chat as a bare grey box. Same site, same layout, same deployment.

The share image was defined exactly once, in the root layout, where it belongs. The home page had it. Almost nothing else did.

What happens

In the App Router every layout and every page can export metadata or generateMetadata. Next.js merges them from the root down to the page. The detail that matters is how deep that merge goes: it is shallow. Top-level keys are merged, and when a page defines a key the layout also defines, the page's value replaces the layout's value completely.

openGraph is one such key. So with this in the layout:

// app/layout.tsx
export const metadata = {
  openGraph: {
    siteName: "Example",
    images: [{ url: "/og.jpg", width: 1200, height: 630 }],
  },
};

and this in a page:

// app/services/page.tsx
export const metadata = {
  openGraph: { title: "Services", url: "https://example.com/services" },
};

the rendered page has an Open Graph title and address, and no image and no site name. The page did not add two fields to the layout's object. It replaced the object.

The home page worked because it defined no openGraph of its own and inherited the layout's untouched. Every page where I had been careful enough to set a proper title and address had lost the image as a reward.

The same rule applies to twitter, robots and alternates. Any nested object you define on a page stands alone.

How to see it

View source lies less than any preview tool, and curl is quicker than both.

for p in / /services /blog /about; do
  printf "%-12s " "$p"
  curl -s "https://example.com$p" | grep -o '<meta property="og:image"[^>]*>' | head -1
  echo
done

A line with a path and nothing after it is a page without a share image. I ran this over the whole sitemap and got a result that was easy to read: posts and tool pages had an image, because those pages set their own cover. Everything else was empty.

That is also why the bug had survived. The pages that get shared most, the posts, were fine.

The fix

There is no deep-merge switch. The page has to carry the complete object. To keep that from turning into copy and paste, the shared part lives in one constant and every page spreads it in.

// lib/seo.ts
export const OG_IMAGES = [{ url: "/og.jpg", width: 1200, height: 630 }];
// app/services/page.tsx
import { OG_IMAGES } from "@/lib/seo";

export const metadata = {
  openGraph: {
    images: OG_IMAGES,
    title: "Services",
    url: "https://example.com/services",
  },
};

Pages with a better picture of their own, such as a post with a cover, pass that instead. The constant is the fallback, and it is now an explicit one.

If you would rather have real inheritance, generateMetadata receives the parent's resolved metadata as its second argument, and you can read the images from there:

export async function generateMetadata(_: unknown, parent: ResolvingMetadata) {
  const previous = (await parent).openGraph?.images ?? [];
  return { openGraph: { images: previous, title: "Services" } };
}

That works and it is what the documentation suggests. I chose the constant because it is one import, it cannot be forgotten silently on a page that uses the static metadata export, and a search for OG_IMAGES shows me every page that has it.

Make it a check

A missing share image does not fail a build, does not show up in a Lighthouse score and does not throw anything in the browser. You find it when a link looks bare in somebody's chat window, or when you go looking. So the curl loop above went into the short list of things I run against a preview deployment before it becomes production, together with the title and the canonical.

Three lines of shell over the sitemap:

curl -s https://example.com/sitemap.xml | grep -o '<loc>[^<]*' | sed 's/<loc>//' |
while read -r u; do
  curl -s "$u" | grep -q 'property="og:image"' || echo "no og:image: $u"
done

No output means every page has one.

The general lesson

The layout is where shared metadata feels like it should live, and for simple keys like title templates or metadataBase it does. For nested objects the layout only supplies a default for pages that say nothing. The moment a page speaks, it has to say everything.

The fix was one constant and one line in each page file. The check that finds it next time is three lines of shell.