Three Next.js metadata traps that quietly de-index pages
We found all three in our own production site. Each one passed review because it never appears on screen, and each one cost us search visibility without a single error. This is what they look like, how we found them, and the shape of the fix.
Trap 1: shallow-merged alternates drop hreflang
Our root layout declared language alternates (en-US, en-GB, en-CA, en-AU and an x-default). For months, none of them was ever served. Next.js merges page metadata one top-level key at a time, so any page that set its own canonical-only alternatesobject replaced the layout’s whole alternates object and silently dropped the languages.
// layout.tsx: canonical plus the language map
alternates = { canonical, languages }
// page.tsx: a canonical-only object replaces it, languages are gone
alternates = { canonical }The fix was a single helper that returns the canonical and the languages together, then a sweep of 102 files to use it. We also added a source check that fails the build when a page writes a raw canonical-only block, and confirmed it fails when the old pattern is put back.
Trap 2: a per-field openGraph drops og:image
The same merge rule applies to openGraph. Our root image came from an opengraph-image file, injected as the images entry. A page that declared openGraph: { title, description } replaced that object, and the image went with it. 74 files did exactly this, and 146 routes were serving no og:image.
Nothing looked broken: the page rendered and the title was right. The gap only showed up when the head of the response was read. Entity sub-pages now point at their parent entity’s card, and a build check fails any page that declares openGraph without images.
Trap 3: a cached null pins a soft 404 for an hour
Our entity pages load data through unstable_cache, and a miss returned null. The page turned null into notFound(), which serves a noindex directive. But the cache stores whatever the callback returns, including null. One transient miss pinned a healthy, sitemap-listed page to a removal directive for the cache lifetime, up to an hour.
That is why it looked random. A per-request blip would clear on the next request; this stayed broken for an hour, then healed on its own. We confirmed against the Next.js 16.2.9 source that nothing is cached when the callback throws, so the fix is to throw a private error on a miss inside the cached function and convert it back to null outside the cache boundary.
class Miss extends Error {}
const cached = unstable_cache(async (slug) => {
const data = await load(slug);
if (!data) throw new Miss(); // nothing is cached
return data;
}, ['entity']);
async function get(slug) {
try { return await cached(slug); }
catch (e) { if (e instanceof Miss) return null; throw e; }
}We also log every notFound() by reason, and treat the fault-shaped reasons as errors rather than info, so a real fault is not buried under routine bot probes.
The check that was not checking
We already had length limits on titles and descriptions. They only matched 15 of 125 metadata files, about 12%. The pattern expected a closing form that only one style of metadata block used, so the other 110 files fell out of the check entirely. A rule that matches nothing reads exactly like a rule that passes.
Two habits came out of it. Test every guard in both directions: put the defect back and see it fail, then remove it and see it pass. And add a check on the rendered output, because source analysis cannot see what a crawler reads.
Frequently asked questions
Why is hreflang not showing on my Next.js pages?
Most likely a page sets its own alternates object, which replaces the parent layout's alternates entirely instead of adding to it. Metadata merges one top-level key at a time, so a page that only declares a canonical drops the languages the layout declared. Build the whole alternates object, canonical and languages together, from one helper and use it on every page.
Why is og:image missing when my layout sets one?
The same shallow merge applies to openGraph. A page that declares openGraph with a title and description replaces the parent's whole openGraph object, including its images entry. Declare images again on every page that sets openGraph, or have a shared helper do it.
Can a cached null cause a soft 404?
Yes. unstable_cache stores whatever the callback returns, including null. If a page turns null into notFound(), a single transient miss is cached and every visitor, crawler included, gets a not-found response with noindex until the cache entry expires. Throw on a miss inside the cached function so nothing is cached, then convert the error back to null outside it.
Why did these pass code review?
None of them changes what a visitor sees. The page renders, the title shows, the share card looks fine in the places that fall back to defaults. The failure is only in the head of the HTML a crawler reads, so it needs a check on the rendered output, not the source.