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.

4 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.

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.

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.

Back to all articles