Skip to content

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.

10 min read

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:

  1. View source. Not the inspector - the inspector shows the hydrated DOM. curl -s https://example.com/de/pricing | grep alternate shows what the crawler receives.
  2. Search Console → International Targeting. Reports "no return tags" and "unknown language code" errors across the whole property.
  3. 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.

Related questions

Does hreflang tell Google which country to show the page in?
No. It declares the language a page is written for, and optionally the region that language variant serves. Country targeting is a separate signal. A page marked hreflang="de" is offered to German speakers wherever they are, not to visitors in Germany.
Our canonical points at the English page on every locale. Is that a problem?
It is the single fault that destroys a whole cluster. A canonical says "index this other URL instead", so every translated page is telling Google not to index it. The alternates are then pointing at pages that have opted out. Each locale must be canonical to itself.
Should hreflang go in the head or in the sitemap?
Either works, and both is fine as long as they agree exactly. A disagreement between the two is resolved unpredictably, which is worse than emitting only one of them. Generate both from the same source and they cannot disagree.
We only translated half the site. Is a partial cluster still valid?
Yes, and it is the normal case. A cluster is the set of locales a specific page exists in, not the set the site supports. The failure is not partial coverage - it is advertising a translation that was never written.

← Back to all articles

Call us+1 848 272 7583WhatsApp+90 850 308 5436Emailinfo@codefacture.comContact page