DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Next.js merges metadata from the root down, and three of the fields must never be inherited

Next's metadata API resolves from the root layout downwards and merges field by field. Anything you set in the root layout lands on every page that does not set that field itself.

That is a genuinely good design, and it makes exactly the wrong thing convenient. The fields it is most tempting to hoist up to the root are the ones that are per-page by definition, and the failure mode is not an error, a warning or a visible defect. It is a correct-looking page carrying a true statement about a different URL.

Here is what munchable.app ended up with after getting two of these wrong.

A canonical in the root layout is a deindexing device

Setting alternates: { canonical: '/' } once, at the root, feels like good hygiene. Here is what it actually does.

The site has pages that must never be indexed: the checkout return pages, the session handoff route, the signed-in entry point, the 404. They all declare noindex. With a canonical inherited from the root, each one now serves noindex plus a rel=canonical pointing at the homepage.

Those two directives contradict each other. A canonical says "treat this URL and the target as the same thing, and consolidate onto the target"; noindex says "drop this URL". Google's canonicalization guidance explicitly tells you not to mix noindex into canonicalization, and the risk if you do is that the directive is applied to the consolidated URL rather than the one that declared it. The page you were protecting is now making an argument about the one page on the site that has to rank.

So the root layout sets metadataBase, a title template, a description, a site name, and no alternates at all. Canonicals come from one helper that derives them from the page's own path:

const url = absoluteUrl(opts.path);
return {
  // A noindex page gets NO canonical at all.
  ...(opts.noIndex ? {} : { alternates: { canonical: url } }),
  // ...
};
Enter fullscreen mode Exit fullscreen mode

A page's canonical is either its own URL or absent. There is no third state, and no segment anywhere in the tree can introduce one.

An inherited og:url makes every share preview as your homepage

Same mechanism, less dramatic consequence, much easier to ship.

openGraph.url in the root layout is inherited by every page that does not set its own. The pages that need it least are the ones that get it: send somebody a link to a checkout-return page and the unfurled preview shows your homepage, title, description and all. The link is right, the card is about something else, and nothing in your logs will ever tell you.

openGraph.siteName, type and locale are genuinely site-wide and live at the root. url and title are not, and they come from the same per-page helper as the canonical, derived from the same single path argument. One input, so the canonical and the og:url cannot drift apart.

Robots is the only directive that is safe to inherit

There is one directive that says the same true thing about every page beneath it, and that is robots. A segment-wide floor for it is not a hack, it is the point of inheritance:

export const NO_INDEX: Metadata = {
  robots: { index: false, follow: false, nocache: true },
};
Enter fullscreen mode Exit fullscreen mode

That is the entire metadata export of the /premium layout. The two pages under it set noIndex themselves, because that is how they get their own Open Graph URL, so the layout is a floor rather than the mechanism: anything added under /premium next year is out of the index even if its page forgets. An indexed "Checkout cancelled" page ranking for your own brand name is a uniquely annoying way to lose a sale.

The corollary caught me out on a different page. The maintenance page renders at other pages' URLs, and a noindex on it would be served at /support, which is a request to drop /support from the index. A page that looks exactly like a noindex candidate is the one page here that must not declare one. The directive is not about the component, it is about the URL it answers at.

Naming your own og:image deletes the alt text

This one has nothing to do with inheritance and everything to do with the file convention, and it is my favourite because the fix contains its own explanation.

An opengraph-image.png in the app directory is picked up automatically and fills in openGraph.images. The condition nobody reads: it only does that for a segment that does not set openGraph itself. Every page here sets openGraph, because every page needs its own title, description and url. So without one explicit line, the homepage had a share card and no other page did.

Naming the image explicitly fixes that, and immediately breaks something else:

const OG_IMAGE = {
  url: '/opengraph-image.png',
  width: 1200,
  height: 630,
  // Naming the image here also replaces the alt text the file convention would
  // have supplied, so it has to be restated or og:image:alt is emitted nowhere.
  alt: `The ${SITE_NAME} cookie logo beside the words "${SITE_TAGLINE}".`,
};
Enter fullscreen mode Exit fullscreen mode

The convention also supplies og:image:alt from an opengraph-image.alt.txt file. Take over openGraph.images and that goes with it, silently, because an object with no alt key is a perfectly valid image entry.

You can check both on any page: view source on the answers index and you will find og:image and og:image:alt on it, same as on the homepage.

The title template, and the budget it has to fit in

The root sets template: '%s | Munchable', so pages declare only their subject and the brand is appended once, in one place.

That works until the budget bites. A title of 60 characters or more gets truncated in a search result, and the suffix costs twelve of them. On the question pages, whose title tag has to be the question somebody typed, twelve characters is frequently the difference between a whole title and a cut one. So the helper drops the suffix rather than the subject:

function resolvedTitle(title: string): string | { absolute: string } {
  return title.length + TITLE_SUFFIX.length < TITLE_LIMIT ? title : { absolute: title };
}
Enter fullscreen mode Exit fullscreen mode

{ absolute } opts out of the parent template entirely. Two live pages show both sides of the branch:

A reader scanning results is matching the question, not the brand, and the brand is in the URL and the breadcrumb beside it anyway. There is a test that reads every generated title and fails the build on the budget, which I wrote about in fifty nine characters; the related decision about letting crawlers fetch pages we never want indexed is here.

The rule that came out of it

Ask one question of every field before it goes in a layout: is this statement true of every URL underneath, including the ones that do not exist yet?

siteName passes. robots passes for a segment that is entirely private. canonical, openGraph.url and anything else that names a specific URL can never pass, because they are not statements about a subtree. They are statements about a page, and the only code that should produce them is code that was handed that page's path.

Top comments (0)