The App Router emits hreflang through alternates.languages in generateMetadata:
export async function generateMetadata({ params }): Promise<Metadata> {
const { slug } = await params;
return {
alternates: {
canonical: absoluteUrl(`/blog/${slug}`),
languages: {
en: absoluteUrl(`/blog/${slug}?lang=en`),
fr: absoluteUrl(`/blog/${slug}?lang=fr`),
},
},
};
}That is the mechanically correct answer, and it is where most guides stop. The interesting failure is not in the syntax.
The trap: your fallback makes every page multilingual
Most localisation helpers fall back. Ask for French, get English if no French exists — which is right, because a missing translation should not produce an empty page.
The side effect is that every page renders successfully in every language you support. Nothing throws. Nothing looks wrong. So a hardcoded language map is very easy to write, and it will claim a French edition for an article that has only ever existed in English.
Google's own guidance is that annotations must be reciprocal and must point at genuinely different-language versions. A page that returns English under ?lang=fr is not a French version, and hreflang annotations that do not hold up tend to be ignored wholesale rather than partially honoured. So the failure mode is not "a slightly wrong tag" — it is the whole cluster being discarded, including the entries that were correct.
Derive the list from stored content
The fix is to compute the languages from whatever you actually stored, not from a constant:
function availableLanguages(contentJson: unknown): string[] {
let parsed: unknown = contentJson;
if (typeof parsed === "string") {
try { parsed = JSON.parse(parsed); } catch { return ["en"]; }
}
if (!parsed || typeof parsed !== "object") return ["en"];
const langs = Object.entries(parsed as Record<string, unknown>)
.filter(([, v]) => typeof v === "string" && v.trim().length > 0)
.map(([k]) => k);
return langs.length ? langs : ["en"];
}Two details matter more than they look.
The trim().length > 0 check is doing real work. A record created with { en: "...", fr: "" } — an empty string from a translation step that ran but produced nothing — is indistinguishable from a real translation if you only check for the key's presence.
And the catch returning ["en"] matters because metadata generation runs for every request to the route. A malformed row should cost you a correct annotation, not a 500 on the page.
Regional variants, and what they are for
pt and pt-AO are not competing entries; the regional one narrows the audience of the same content:
const REGIONAL_VARIANTS: Record<string, string[]> = {
ar: ["ar-AE"],
pt: ["pt-AO"],
};Emit the regional variant alongside the base language, pointing at the same URL. Portuguese written for Angola is still Portuguese; pt-AO tells Google which Portuguese-speaking market it is aimed at without splitting it from Portuguese readers generally. Only add a variant where the targeting is real — inventing en-AU for a page with nothing Australian in it is the same lie in a smaller font.
x-default
x-default names what an unmatched visitor should get:
{ "x-default": absoluteUrl(`/blog/${slug}`) }Point it at whatever your bare URL genuinely serves. Worth checking rather than assuming: if your route reads searchParams.lang with a default, the bare URL serves that default — which may not be the language you think of as the page's primary one.
Verifying it
Check the rendered HTML, not the source:
curl -s https://example.com/blog/some-post \
| grep -oE '<link[^>]*alternate[^>]*>'Then confirm each URL returns the language it claims. The quickest way to be wrong here is to grep for a word from the title — a localised metaTitle will match on a page whose body is entirely English, which proves nothing about the content.
The rule
Advertise only what you have, derive it from what you stored, and check the served page rather than the template. hreflang that reflects reality is a modest SEO gain; hreflang that does not is a cluster Google throws away.