Next.js Open Graph Metadata Replaces, It Doesn't Merge
A child route that sets its own openGraph object silently drops the layout's og:image and og:locale. Why Next.js does that, and the helper this site uses to stop it.
The symptom: share cards that lose their image
You set a default share image and og:locale in the root layout's openGraph. A page then sets its own openGraph to give itself a proper title. Shared on a messaging app or a social network, that page now has no image, and the locale tag has gone from its head. Nothing errors. This note is for anyone using the Next.js App Router Metadata API who has hit that, or is about to.
Why: metadata is merged shallowly
Next.js evaluates metadata from the root layout down to the page and merges the objects shallowly. Its own documentation for generateMetadata says that nested fields such as openGraph and robots defined in an earlier segment are overwritten by the last segment to define them. So openGraph is one key. If the page defines it at all, the page's object replaces the layout's whole object, and every field the page did not repeat, images, locale, siteName, type, is gone.
Top-level keys like title and description behave the way you would expect, which is why this catches people: the page's title appears correctly in the tab and in search results, and only the share preview is wrong.
The fix on this site: a helper that reapplies the defaults
Rather than remember the defaults on every page, every route builds its openGraph and twitter blocks through two helpers in src/lib/seo.ts. The comment on the default image states the reason:
/**
* Referenced explicitly rather than relied on implicitly: a route that defines
* its own `openGraph` object replaces the parent's instead of merging into it,
* so `locale` and `images` silently disappear from every child route unless
* they are set again. This helper is what stops that happening per page.
*/
export const OG_IMAGE = { url: "/opengraph-image", width: 1200, height: 630, alt: "..." };openGraphFor() takes the page's title, description, URL and type, and returns a complete object: the title suffixed with the site name, siteName, locale (en_MY here), and images set to either the page's own image or the default. For type: "article" it also adds authors, publishedTime, modifiedTime, section and tags when given. twitterFor() does the same for the Twitter card, defaulting to summary_large_image and the same image.
openGraph: openGraphFor({
title,
description,
url: "/work/" + project.slug + "/case-study",
type: "article",
...(share ? { image: { url: share.src, width: share.width, height: share.height, alt: share.alt } } : {}),
}),
twitter: twitterFor(title, description, share?.src),Per-article images with the file convention
Articles get their own card. src/app/blog/[slug]/opengraph-image.tsx uses ImageResponse to draw the article title, category and site wordmark at 1200 by 630, with generateStaticParams listing the published articles. It uses no remote fonts, so it renders the same everywhere, and it reduces the headline size for titles longer than 60 characters.
The article page also names that image explicitly in its own openGraph and twitter blocks, with width, height and the title as alt text, rather than relying only on the file convention. File-based metadata does take priority over the metadata object, but naming it means the JSON-LD image field, the Open Graph tag and the Twitter tag are visibly built from the same URL in one place. The structured-data side is described in One Person, Referenced Everywhere.
How to check your own site
View the source of a deep page, not the homepage, and look for og:image and og:locale. If either is missing on a page that sets openGraph, this is the cause. The fix does not need a library: one function that returns a complete object, used by every route. The related canonical-URL problem, where og:url named a URL that redirects, is described in Canonicals That Pointed at a Redirect.
Keep reading
Canonicals That Pointed at a Redirect: www vs Apex, Sitemaps and Permanent Redirects in Next.js
Every canonical, og:url and sitemap entry on this site named a URL that 308-redirected. The fix, plus the sitemap, feed and redirect decisions made around it in Next.js.
One Person, Referenced Everywhere: A JSON-LD @graph for a Personal Site
How this site declares its Person and WebSite nodes once and points every article, project page and the homepage at them by @id, and what that forced me to leave out.
Theme-Specific Logos Without a Flash: Let CSS Pick, Not useTheme()
A logo with an opaque white background became a white block on the dark theme. Swapping it in CSS on data-theme, not with useTheme(), fixed it on the first frame.