The request came from the client's SEO side, not from me: the site's RSS feed had to be discoverable. When I opened the route, two things were wrong at once. /rss.xml was alive and returning 200, but what it served was mockup articles from a sample content module, not the real news from the CMS. And nothing on the site linked to it. A feed with fake contents, and anyone who might have wanted it would never find out it existed.
The site runs on Next.js 16, so the discovery link would be declared through the Metadata API.
First pass: make the feed real
This part was straightforward. I wired the feed route to the same data source the listing pages use, through getLatestPosts(50), added an atom:self link inside the document, and exposed a sitewide RSS alternate link through alternates.types in the root layout metadata.
// root layout
export const metadata = {
alternates: {
types: {
"application/rss+xml": "/rss.xml",
},
},
};The effect showed up all the way in the build output. The feed route flipped to Dynamic, where it had previously been a static placeholder. I verified the data live: 50 real items, with atom:self where it belonged.
At that point I thought the work was done. The feed was real, and its discovery link was declared in one place that applied to the whole site. Exactly the pattern people reach for on this kind of task.
Declared sitewide, absent in practice
That alternate link was being dropped on every page that set its own metadata. The only pages still carrying it were the ones that overrode nothing.
Nothing blew up, and nothing was ever going to. A head link has no appearance on screen, throws no error, and changes nothing a visitor can see. The only way to know is to open the <head> of the page in question and look for it yourself. If the page you happen to open is one that sets no metadata of its own, the link is right there, and you walk away with the wrong conclusion.
The root cause: the alternates object is replaced whole
A page's metadata.alternates replaces the layout's. Not a deep merge, a replacement. The deeper object wins in its entirety, and nothing from the object above it comes along.
What made this bite on every page at once was the shared metadata helper I had been using for a long time. That helper's whole job is to set the canonical, and the canonical was already param-free, written as alternates.canonical = path:
// lib/metadata.ts, before the fix
export function buildMeta({ title, description, path }) {
return {
title,
description,
alternates: {
canonical: path,
},
};
}So every page that called this helper shipped an alternates object whose only key was canonical. That object is the one that wins. The helper's { canonical } clobbered the layout's { types }, on every page, silently. Not because anyone wrote code to remove it, but because two objects sharing the same key are never fused.
The neighboring field does inherit
Here is what makes this easy to guess wrong: inside the same metadata object, title behaves the opposite way. The title template in the root layout, shaped as %s | Brand, composes itself with the page's own title. In the same project I needed an explicit escape hatch for exactly that, because the custom titles coming from the client side were already length-tuned without the brand name, so those pages had to ask for an absolute title to keep the template suffix from being appended.
// root layout
export const metadata = {
title: {
template: "%s | Brand",
default: "Brand",
},
};
// a page whose title must not take the suffix
export const metadata = {
title: { absolute: "A title already tuned for length" },
};Two fields, two rules. title combines the layout value with the page value so insistently that you have to go out of your way to opt out. alternates combines nothing at all, it simply swaps. What was wrong in my assumption was not a detail of one field, it was the belief that metadata inheritance applies uniformly across the whole object.
The fix: emit it from the same place that sets the canonical
Once you know the object gets replaced whole, the fix is simple and slightly boring: move the RSS types link into the object that is guaranteed to win. That is the same helper, right next to the canonical.
// lib/metadata.ts, after the fix
export function buildMeta({ title, description, path }) {
return {
title,
description,
alternates: {
canonical: path,
types: {
"application/rss+xml": "/rss.xml",
},
},
};
}Now the RSS link travels wherever the canonical travels, because both leave from the same object. I verified the result live in production, with the head link present on the homepage and on a dynamic detail route.
The takeaway
For sitewide head links through the Metadata API, the place to put them is not only the root layout but the shared per-page helper, the same place that sets the canonical. The reason fits in one sentence: Next overrides the entire alternates object per page, so anything that lives only in the layout vanishes the moment a page fills that object with its own version.
When you go to verify a link like this, pick a page that sets its own metadata rather than one that overrides nothing. The second kind will always give you a pleasant answer that means nothing.
I have run into the same pattern across two projects, and the shape is always similar: something declared once at the root, assumed to apply everywhere, then quietly cancelled by a deeper object.