Next.js Open Graph: why your pages share with no image
Next.js Open Graph metadata on a page replaces the layout's instead of merging. How one small helper fixed twenty pages that shared as bare links with no image.
By Hamza Ghouri
If links to your Next.js site show up in Slack, WhatsApp or LinkedIn as plain text with no picture, the cause is often one line of metadata. In Next.js Open Graph settings, a page that defines its own openGraph object replaces the layout's openGraph entirely. Your default share image, site name and URL go with it. This post explains why that happens, how I found twenty affected pages on a stock photo site, and the small helper that fixed all of them at once.
Why a page's openGraph wipes out the layout's
The site is Fotobank, a stock photo marketplace built on Next.js. The root layout set a sensible default: a branded share card, the site name and a locale. Every page then exported its own metadata with a title and description, and many also set openGraph.title and openGraph.description so the share preview would say something specific.
That second step is where it broke. Next.js merges metadata from the layout and the page, but the merge is shallow. The generateMetadata reference says segments are "shallowly merged" and duplicate keys are replaced based on their ordering. openGraph is one key. When a page sets it, the page's object wins as a whole, and anything it leaves out is gone. A page that set a title and description under openGraph but no images ended up with no og:image tag at all.
It is an easy mistake because the layout looks like a default. You expect a nested object to fill in the gaps the way CSS cascades. It doesn't. The same rule applies to twitter, alternates and every other nested metadata field.
How to find pages that share with no image
On Fotobank this affected twenty pages: photo pages, categories, the blog, news, events, about, the license page and more. Nothing looked wrong in the browser. The page rendered fine, the title in the tab was right, and the only symptom was a bare link wherever someone shared it.
The quickest check is the rendered HTML. For each route type, open one real URL, view source and search for og:image. If it is missing, or present on the homepage and missing on a photo page, you have this bug. I check one URL per route, since every page in a route shares the same metadata code.
A few things are worth checking while you are in there:
og:urlshould be the page's own URL. If it is missing, some platforms fall back to whatever URL they were given, which is fine, but a canonical value is better.og:site_nameusually comes from the layout, so it disappears in the same way.- Image URLs must be absolute in the final HTML. Next.js resolves relative image paths against
metadataBasein the root layout. The docs say a relative URL with nometadataBaseconfigured causes a build error, so set it once and keep image paths relative.
One helper for every page's Next.js Open Graph tags
I didn't want twenty pages each repeating the site name, URL and fallback image. That is how the bug started: every page had to remember the defaults, and most didn't. So every page now goes through one helper, pageOpenGraph, that adds the page URL and site name and falls back to the default share card when the page has no image of its own.
import type { Metadata } from "next";
type OpenGraph = NonNullable<Metadata["openGraph"]>;
const SITE_NAME = "Fotobank";
const SHARE_IMAGE = "/og-default.jpg";
export function pageOpenGraph(path: string, og: OpenGraph): OpenGraph {
const images = og.images ? [og.images].flat() : [];
return {
siteName: SITE_NAME,
url: path,
...og,
images: images.length ? images : [SHARE_IMAGE],
};
}
A page then passes only what is specific to it:
export async function generateMetadata({ params }) {
const { slug } = await params;
const photo = await getPhoto(slug);
return {
title: photo.title,
openGraph: pageOpenGraph(`/image/${slug}`, {
title: photo.title,
images: photo.mediumUrl,
}),
};
}
The spread order matters. Defaults go first so a page can override them, and images goes last so an empty or missing value can never knock out the fallback. The helper is small on purpose. It doesn't build titles or descriptions, because those differ too much between page types to be worth abstracting.
Fallbacks for Twitter/X and posts with no cover
Twitter/X reads its own twitter:* tags, and the twitter metadata key has the same shallow merge behaviour. On Fotobank, blog and news posts without a cover image also fall back to the default card for Twitter/X, so a post with no cover still shares with a picture on every platform.
My rule for any fallback: decide it once, in one place, and make the page opt out of it rather than opt in. Pages are written by different people at different times. A default that each page must remember to include will be forgotten on the next page someone adds.
The same pattern on this site
The site you are reading uses the same idea. One pageMeta helper builds the title, description, canonical URL, Open Graph and Twitter tags for every page, from a title, a description and a path. The homepage, the projects page, the blog index and every post call it. Nothing sets openGraph by hand, so nothing can drop the share image by accident.
A helper like this also makes audits cheap. When I want to change the default share card or add a field, there is one function to edit and one place to review. I took the same approach to feeds on Fotobank, where the Pinterest RSS feed for auto-publishing builds every item from one function.
Small bugs you find on the way
Going through every page's metadata turned up a side bug: a topic page built its title as the topic name plus "News", so a topic called "Industry News" was titled "Industry News News". Bugs like that survive because people rarely read the titles of pages they didn't just change.
That is the other benefit of moving metadata into one helper. You end up reading every page's metadata once, side by side, and odd output stands out. Fotobank had other page-level bugs of the same quiet kind, written up in fixing React hydration errors from dates and modals and measuring a fixed header's height into a CSS variable.
Do you need a unique og:image for every page in Next.js?
No. A page with a strong image of its own (a photo, a product, a post cover) should use it, because it makes the share preview specific. Everything else can use one well-designed default card. The only real failure is no image at all, and a fallback in a shared helper prevents that.
Where I'd start
- View source on one URL per route type and search for
og:image. - Set
metadataBasein the root layout so relative image paths resolve. - Route every page's
openGraphthrough one helper that fills in the site name, URL and a fallback image. - Do the same for
twitter, since it is merged the same shallow way.