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.
The fault that destroys a cluster: canonical disagreeing with hreflang
Everything above assumes each page is canonical to itself. When it is not, none of it works, and this is the most damaging error we find in the wild.
The pattern looks reasonable in a pull request. Somebody worries about duplicate content across languages, and points every locale's canonical at the English original:
// Every translated page now asks not to be indexed
alternates: {
canonical: 'https://example.com/pricing',
languages: { en: '...', de: '...', es: '...' },
}A canonical means "index this other URL instead of me". So the German page is asking to be dropped, the Spanish page is asking to be dropped, and the alternates are pointing at pages that have opted out of the index. The cluster collapses to one page, and the symptom - translated pages that never appear in their own language's results - looks nothing like its cause.
Translations are not duplicate content. Each locale is canonical to itself, and hreflang is what tells the crawler they are versions of one another. That is the entire division of labour between the two tags.
Automatic redirects, and why the crawler may never see your other locales
The second structural fault is not in the tags at all. It is a middleware that
reads Accept-Language and redirects.
Googlebot crawls predominantly from US IP addresses with an Accept-Language
of en-US, and it does not vary that to discover what else you have. If a
request for /de/pricing is redirected to /pricing because the crawler did
not ask for German, then the German page does not exist as far as the index is
concerned - and every hreflang tag pointing at it is pointing at a redirect.
The rule is that language negotiation may suggest, never compel:
- Redirect only from a bare root that has no locale of its own, never from a URL that already names one.
- Make the choice sticky in a cookie, so a visitor who overrides the guess is not fought on every navigation.
- Offer the alternative as a dismissible banner rather than a redirect where you can.
A locale prefix present in the URL is a statement of intent, from a human or a crawler. Overriding it is how sites end up with three locales that nobody outside the office has ever been served.
When only some pages are translated
Partial coverage is the normal state of a real site, and it is not an error. The mistake is treating the cluster as a property of the site rather than of the page.
The site supports four languages; this particular article exists in two. Its cluster has two entries. The article next to it has four. Both are correct, and both are computed rather than declared - which is the whole reason the derived approach above is worth the small amount of code it takes.
The one case that needs a decision is a page that exists in a locale but is
untranslated - published in English inside a German-prefixed URL because the
navigation needs the route to resolve. Either translate it or leave it out of
the German build. What you should not do is serve English at /de/... and tag
it hreflang="de": you have then told the crawler that German speakers have a
German page, and the page is in English.
noindex and hreflang do not mix
A page carrying noindex cannot participate in a cluster. It will be dropped,
and because hreflang is bidirectional, its absence weakens the reciprocity of
every page that pointed at it.
This bites on staging domains and on preview deployments that carry production
metadata. If a preview environment serves the same alternates as production,
you are advertising a cluster whose members answer with noindex. Serve
Disallow: / and a noindex on previews, and derive the origin in the
metadata from the deployment rather than hard-coding it in a way previews
inherit.
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.
There is a fourth, and it is the only one that catches the fault before it ships. This site fetches every URL in its own sitemap on each push and fails the build when a page is missing from its own hreflang cluster, when a canonical is absent or points somewhere else, or when a duplicate URL form answers instead of redirecting. A generated cluster removes the class of error where somebody forgets to update a list; a check in CI removes the class where the generation itself is wrong.
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.
Worth saying which markets make this expensive to get wrong. A bilingual Canadian site has a legal reason for both languages to be complete and current, not only a commercial one - the Canada page sets out why parity has to be enforced in CI rather than reviewed by hand.
