Next renders <link rel="alternate" hrefLang> through React DOM, which writes the prop name verbatim, in camelCase — so grep hreflang on the served HTML returns zero and you conclude the tags were never emitted. They were. The two failures that actually cost indexation are quieter: canonicalizing every locale to /en, which discards the cluster, and a route-level alternates object, which replaces the layout's rather than merging into it. All three are catchable with one pass over the build output, which is the only artifact that tells the truth.
The symptom
violettadev.com serves 8 locales out of a single [locale] segment. src/i18n/routing.ts:
export const routing = defineRouting({
locales: ["en", "es", "pt", "fr", "it", "ko", "ja", "zh"],
defaultLocale: "en",
localePrefix: "always",
});I built, opened the prerendered Spanish page, and grepped for the thing I had just written code to produce:
$ grep -c "hreflang" .next/server/app/es/templates.html
0
$ echo $?
1Zero. Not "seven of eight locales" — nothing at all. So I went looking for the reason alternates was being dropped: next-intl interfering with metadata, metadataBase misconfigured, the static export stripping head tags. None of that was happening. The tags were in the file the whole time.
The casing is not what the docs show
Next's own documentation ships in the package, and node_modules/next/dist/docs/01-app/03-api-reference/04-functions/generate-metadata.md line 844 shows this as the head output for alternates:
<link rel="canonical" href="https://nextjs.org" />
<link rel="alternate" hreflang="en-US" href="https://nextjs.org/en-US" />Lowercase. Here is what my build actually contains:
$ grep -o 'rel="alternate" hrefLang="[a-zA-Z-]*" href="[^"]*"' \
.next/server/app/es/templates.html
rel="alternate" hrefLang="en" href="https://violettadev.com/en/templates"
rel="alternate" hrefLang="es" href="https://violettadev.com/es/templates"
rel="alternate" hrefLang="pt" href="https://violettadev.com/pt/templates"
rel="alternate" hrefLang="fr" href="https://violettadev.com/fr/templates"
rel="alternate" hrefLang="it" href="https://violettadev.com/it/templates"
rel="alternate" hrefLang="ko" href="https://violettadev.com/ko/templates"
rel="alternate" hrefLang="ja" href="https://violettadev.com/ja/templates"
rel="alternate" hrefLang="zh" href="https://violettadev.com/zh/templates"
rel="alternate" hrefLang="x-default" href="https://violettadev.com/en/templates"Nine tags, exactly as intended, invisible to a case-sensitive grep.
The mechanism: metadata head tags are ordinary React elements, and React DOM serializes an attribute using the prop name as written. React does know the lowercase spelling — react-dom-server-legacy.node.development.js:8652 carries hreflang: "hrefLang" inside a table of author misspellings — but that map exists to warn you when you type lowercase in JSX, not to normalize output. Six lines settle it, no Next involved:
import { renderToStaticMarkup } from 'react-dom/server'
import React from 'react'
console.log(renderToStaticMarkup(React.createElement('link', {
rel: 'alternate', hrefLang: 'es', href: 'https://violettadev.com/es',
})))
// <link rel="alternate" hrefLang="es" href="https://violettadev.com/es"/>This is correct HTML — attribute names are case-insensitive, every parser lowercases them, and Google reads hreflang. It breaks the grep, not the page. The same site demonstrates both spellings, because the sitemap goes through Next's own XML writer instead of React:
$ grep -o '<xhtml:link[^>]*/>' .next/server/app/sitemap.xml.body | head -1
<xhtml:link rel="alternate" hreflang="en" href="https://violettadev.com/en" />One codebase, one concept, two casings. Grep for rel="alternate" and stop guessing.
Mistake 1: pointing all 8 locales at one canonical
This is the expensive one, and it is easy to reach honestly — a canonical is "the real URL for this content", and it feels like the real URL is the English one. It is not. rel="canonical" says index that URL instead of this one. An hreflang cluster says these are equivalents; pick per user. Point /es/templates at /en/templates and you have asserted both at once: the Spanish page is a duplicate to be folded away, and also a valid alternate worth serving. Canonical is the stronger consolidation signal, so the cluster is what goes.
The shape that avoids it, from src/lib/seo.ts (buildPageMetadata):
const canonical = `${SITE_URL}/${locale}${path === "/" ? "" : path}`;
const languages: Record<string, string> = {};
for (const l of routing.locales) {
languages[l] = `${SITE_URL}/${l}${path === "/" ? "" : path}`;
}
languages["x-default"] = `${SITE_URL}/${routing.defaultLocale}${path === "/" ? "" : path}`;alternates: {
canonical,
languages,
},Two properties fall out of those nine lines. The canonical interpolates the caller's locale, so every page self-canonicalizes and there is no hardcoded /en to leak. And the cluster is generated from routing.locales — the same array that feeds generateStaticParams — so it is reciprocal by construction: all 8 locales emit an identical set of 8 URLs plus x-default, and there is no hand-maintained per-page list that can drift when a locale is added.
The build agrees, in a locale that shares no alphabet with the default:
$ grep -o '<link rel="canonical" href="[^"]*"' .next/server/app/ja/templates.html
<link rel="canonical" href="https://violettadev.com/ja/templates"The path === "/" ? "" ternary is not cosmetic. Without it the homepage canonical is https://violettadev.com/en/ while every internal link and every hreflang entry points at https://violettadev.com/en — one page, two URLs, self-inflicted duplication at the highest-priority address on the site.
Mistake 2: a route-level alternates silently replaces the layout's
Same docs file, line 1326:
…Metadata objects exported from multiple segments in the same route are shallowly merged together to form the final metadata output of a route. Duplicate keys are replaced based on their ordering.
Shallowly. alternates is one key. So a page that exports
export const metadata = {
alternates: { canonical: "https://violettadev.com/en/pricing" },
};does not add a canonical to whatever the layout declared — it substitutes the entire object, and languages vanishes with it. TypeScript is satisfied, the build passes, the page renders, and that one route quietly leaves the hreflang cluster while its siblings keep advertising it as a member. Non-reciprocal clusters are the failure mode that is hardest to notice, because the page you are inspecting looks fine.
The defense here is structural rather than vigilant. src/app/[locale]/layout.tsx declares metadataBase, title, description, icons — and no alternates at all. There is nothing to inherit, therefore nothing to half-overwrite. Every page has to produce its own, and all of them go through one function:
$ find "src/app/[locale]" -name page.tsx | wc -l
11
$ grep -rl "buildPageMetadata(" "src/app/[locale]" --include=page.tsx | wc -l
11That inverts the usual instinct — declare alternates once in the layout, override per route — and it is safer precisely because the merge is shallow. A forgotten call produces a page with no canonical and no cluster, which any audit catches on the first pass. A partial override produces a page with a plausible canonical and no cluster, which looks healthy in DevTools.
The sitemap that had not moved in weeks
Perfect hreflang is worth nothing if the URLs never get announced. src/app/sitemap.ts reads template and post slugs from Sanity, and it was the only CMS-backed route in the project without a revalidate. Every page route had one; the sitemap did not. So it was fully static: prerendered once, restored from the build cache on every deploy after that, because nothing that changes — the CMS entries — is part of its cache key. It listed 4 templates out of 6, and zero blog posts, while those exact pages were being generated and served in the same build.
The fix is one line, and the comment above it is longer than the fix:
export const revalidate = 3600;64 URLs before, 128 after:
$ grep -c "<url>" .next/server/app/sitemap.xml.body
128
$ grep -c "xhtml:link" .next/server/app/sitemap.xml.body
102416 paths × 8 locales, each entry carrying its 8 alternates. Note the arithmetic: the HTML cluster has 9 entries and the XML one has 8, because sitemap.ts maps routing.locales and stops there. x-default is optional in a sitemap, so that is not a bug — but it is a second list of the same thing, maintained separately, which is exactly how clusters drift.
How to catch it next time
Source greps prove nothing here: buildPageMetadata appears in 11 files whether or not it emits anything. Audit the build. This runs against .next/server/app and checks all three failures at once — self-canonical, cluster size, x-default — plus the sitemap gap:
BASE="https://violettadev.com"
for f in $(find .next/server/app -name '*.html' | grep -vE '_not-found|_global-error'); do
url="$BASE/$(echo "$f" | sed -e 's|.next/server/app/||' -e 's|\.html$||')"
canon=$(grep -o '<link rel="canonical" href="[^"]*"' "$f" | sed 's/.*href="//;s/"//')
[ "$canon" = "$url" ] || echo "CANONICAL $url -> $canon"
n=$(grep -o 'rel="alternate" hrefLang="' "$f" | wc -l)
[ "$n" -eq 9 ] || echo "CLUSTER $url has $n alternates (want 9)"
grep -q 'hrefLang="x-default"' "$f" || echo "XDEFAULT $url missing x-default"
done160 prerendered pages, 160 canonicals, zero output. The URL is reconstructed from the file path rather than read from the page, so a canonical pointing anywhere but at itself fails loudly — which is the whole point.
The second half is the one that surprised me. Diff the set of URLs that claim to be canonical against the set the sitemap advertises:
grep -oh '<link rel="canonical" href="[^"]*"' $(find .next/server/app -name '*.html') \
| sed 's/.*href="//;s/"//' | sort -u > /tmp/canon.txt
grep -o '<loc>[^<]*</loc>' .next/server/app/sitemap.xml.body \
| sed 's|</\?loc>||g' | sort -u > /tmp/locs.txt
comm -23 /tmp/canon.txt /tmp/locs.txt160 canonicals, 128 sitemap entries, 32 URLs in the gap: /faqs, /license, /privacy, /terms in all 8 locales. Indexable, self-canonical, fully clustered, and never announced — because BASE_STATIC_PATHS in sitemap.ts lists four paths and those are not among them. Legal pages are low stakes; the check is not, because that is the same class of drift as the frozen sitemap, just from a hardcoded array instead of a stale cache.
Three greps over the build output, run after every deploy. None of them would have passed on the source.
The same metadata layer ships in the templates I sell — all five build their canonical and hreflang cluster from the locale array, so adding a language changes one line: Next.js templates with i18n built in.
Verified on next@16.2.10, next-intl@4.13.2, react-dom@19.2.7.