Correct hreflang in Next.js, Generated Not Maintained
Most multilingual Next.js sites ship a broken hreflang cluster. The failure is always the same — the tags are written by hand. Here is how to generate them from the content that actually exists.
The hreflang specification is short and the rules are simple. Almost every implementation we audit still gets it wrong, and it is nearly always the same root cause: someone maintains the alternates by hand, the translations move, and the tags do not.
The three rules people break
1. Every page in a cluster must list every page in the cluster, including itself. hreflang is bidirectional. If your English page points at the German one but the German page does not point back, Google discards the relationship. Self-referencing is not optional.
2. Do not advertise a translation that does not exist. Pointing
hreflang="de" at an English page because the German version is "coming soon"
is worse than omitting it. You are telling the crawler that German speakers
have a German page, and then disappointing them.
3. x-default is for the fallback, not for English. It marks the page
served to users whose language you do not target — usually your language
selector, or your default locale if you do not have one.
Why hand-maintained tags always rot
Consider the lifecycle. You launch in English and German. Someone writes the metadata:
// The version that will be wrong within a month
alternates: {
languages: {
en: 'https://example.com/pricing',
de: 'https://example.com/de/pricing',
},
}Then Spanish launches, but only for the marketing pages. Then one German article is unpublished. Then someone renames a slug. Each of those is a separate pull request, in a different part of the codebase, and none of them touches this object.
Six months later Search Console reports "no return tags" on a few hundred URLs and nobody can reconstruct why.
Generate from the content, not from a list
The fix is to make the alternates a derived value. In this site's codebase — which is public — the content layer answers one question:
/**
* Which locales actually have this document. Translations are published as
* they are written, so a page's hreflang cluster must reflect reality.
*/
export function availableLocalesFor(
collection: Collection,
slug: string,
): Locale[] {
return locales.filter((locale) =>
fs.existsSync(path.join(collectionDir(collection, locale), `${slug}.mdx`)),
);
}And the metadata helper consumes it:
const languages: Record<string, string> = {};
for (const l of availableLocales) {
languages[hreflangMap[l]] = localizedUrl(l, path);
}
// `x-default` only makes sense when the default locale really has the page.
if (availableLocales.includes(defaultLocale)) {
languages['x-default'] = localizedUrl(defaultLocale, path);
}
return {
alternates: { canonical: localizedUrl(locale, path), languages },
// ...
};Delete a translation file and the tag disappears on the next build. Add one and it appears. There is no list to forget to update, because there is no list.
Self-referencing comes free: the current locale is in availableLocales by
definition, so it is always emitted.
Language codes, and when to add a region
Use the bare language code unless you genuinely serve different content to different regions:
de— German speakers anywhere.de-AT— only if Austrian visitors get different pricing, stock, or legal text from German ones.
A region code you cannot justify splits your signals across two clusters for no
benefit. The common exceptions are genuine: en-GB versus en-US for
spelling and currency, es-MX versus es-ES for vocabulary and pricing,
pt-BR versus pt-PT.
The region is never a country targeting mechanism on its own. hreflang="de"
does not mean "show in Germany"; it means "this page is for German speakers."
Arabic and other RTL locales
Right-to-left languages need nothing special from hreflang — ar is a language
code like any other. What they do need is correct dir on the document, and
that has to be per-locale:
export default async function LocaleLayout({ children, params }) {
const { locale } = await params;
return (
<html lang={locale} dir={dirFor(locale)}>
{/* ... */}
</html>
);
}Then write your layout in CSS logical properties — padding-inline-start
rather than padding-left, margin-inline-end rather than margin-right,
text-align: start rather than left. Tailwind's ps-*, pe-*, ms-* and
me-* utilities map directly onto these. Do that from the beginning and the
Arabic layout mirrors for free; retrofit it later and you will spend a week
finding the twelve places that did not.
The exceptions that should not mirror: code blocks, email addresses, URLs,
and brand names in Latin script. Set direction: ltr on those explicitly.
Verifying it
Three checks, in increasing order of trust:
- View source. Not the inspector — the inspector shows the hydrated DOM.
curl -s https://example.com/de/pricing | grep alternateshows what the crawler receives. - Search Console → International Targeting. Reports "no return tags" and "unknown language code" errors across the whole property.
- Crawl it. Screaming Frog or Sitebulb will map the full cluster and tell you which pages are missing reciprocal tags. This catches the cases where the tags are individually valid but the graph is incomplete.
The sitemap has to agree
hreflang can live in the HTML head or in the sitemap. If you emit both — and you should — they must match exactly, because a disagreement between them is resolved unpredictably.
The same principle applies: generate the sitemap from the same helper the pages use. We cover that, and canonical URL correctness, in canonicals and sitemaps that cannot drift.
