Rukon
Full-Stack Development3 min read

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.

By

Next.jsOpen GraphMetadata APITechnical SEOTypeScript

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: "..." };
tsFrom src/lib/seo.ts, alt text elided.

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),
tsSimplified from the case-study page's generateMetadata: a project with its own share image passes it; others fall back to the default.

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.