DEV Community

Cover image for hreflang in Next.js 16: 3 mistakes that quietly delete your translated pages from Google
Youssefroop
Youssefroop

Posted on

hreflang in Next.js 16: 3 mistakes that quietly delete your translated pages from Google

TL;DR — If you ship the same page in several languages, hreflang is what tells Google "these are translations of each other, not duplicates." Three mistakes make Google ignore (or actively penalize) your setup: a non-reciprocal cluster, hreflang pointing at URLs that 404, and a canonical that points at the English master instead of the page itself. None throws an error. None fails your build. You only catch them by reading the rendered <head>. Here's the Next.js 16 Metadata API pattern that avoids all three.

Multilingual SEO has a cruel property: the failure mode is silence. Your translated pages render fine, your build is green, TypeScript is happy — and Google quietly decides your French page is a duplicate of your English one and drops it. No error anywhere. This post is the checklist I wish I'd had.

I'll use a fictional example.com throughout. The pattern is framework-light: no i18n library, just the Next.js 16 Metadata API's alternates field and a small helper.


The shape: one helper, per-page locale sets

alternates.languages in the Metadata API renders the <link rel="alternate" hreflang="..."> tags for you. A tiny helper keeps it consistent:

// lib/hreflang.ts
type Locale = "en" | "fr" | "es" | "de" | "nl" | "ar";

/**
 * availableLocales: ONLY the locales where a translated page actually exists.
 * selfLocale: so the canonical is self-referential (the page points at itself,
 *             never at the English master).
 */
export function buildHreflang(
  path: string,
  availableLocales: readonly Locale[],
  selfLocale: Locale = "en",
  baseUrl = "https://example.com",
) {
  const suffix = path === "/" ? "" : path;
  const languages: Record<string, string> = {};
  for (const loc of availableLocales) {
    languages[loc] = loc === "en" ? `${baseUrl}${path}` : `${baseUrl}/${loc}${suffix}`;
  }
  languages["x-default"] = `${baseUrl}${path}`;

  const canonical =
    selfLocale === "en" ? `${baseUrl}${path}` : `${baseUrl}/${selfLocale}${suffix}`;

  return { canonical, languages };
}
Enter fullscreen mode Exit fullscreen mode

Used per page:

// app/es/widgets/page.tsx
export const metadata: Metadata = {
  alternates: buildHreflang("/widgets", ["en", "fr", "es"], "es"),
};
Enter fullscreen mode Exit fullscreen mode

That availableLocales argument is the whole game. It is not the same for every page, and getting it wrong is mistake #2. Pick it per page, from what actually exists:

Page Locales it exists in
/widgets en, fr, es
/pricing en, fr
/guide en, fr, es, de, nl

Don't translate every page into every language just to fill the grid. Translate the pages that match each market — and declare only those.


Mistake #1: non-reciprocal hreflang (the cluster-killer)

Google's rule is bidirectional. If page A says "my Spanish version is B," then B must say "my English version is A." If the link goes only one way, Google doesn't ignore just that edge — it distrusts the entire cluster.

The classic version: three pages in a cluster, two of them declare ["en", "fr", "es"], and the third declares only ["en", "es"] — missing fr.

// app/es/widgets/page.tsx — BROKEN
buildHreflang("/widgets", ["en", "es"], "es")
//                               ^^^^ missing "fr", but /fr/widgets exists and points here
Enter fullscreen mode Exit fullscreen mode

/fr/widgets points at /es/widgets, but /es/widgets doesn't point back. Non-reciprocal → Google treats the translations as unrelated duplicates competing with each other. No error, no warning.

The only reliable defense: grep every hreflang declaration and confirm the locale arrays are identical across a cluster. Same array, every page.


Mistake #2: declaring hreflang to a URL that 404s

Worse than missed signal — this is an outright penalty. Google's docs are explicit: an hreflang annotation pointing to a URL that 404s, redirects, or is noindex is invalid. Enough invalid annotations and Google distrusts your whole setup.

The trap is an aspirational "supported locales" list:

// A loaded gun
const SUPPORTED = ["en", "fr", "es", "de", "nl"]; // ...but /de/* doesn't exist yet
Enter fullscreen mode Exit fullscreen mode

The day someone wires that into a sitemap or a language switcher, it emits <link hreflang="de"> tags to a wall of 404s.

Rule: a "supported locales" list must never be aspirational. It maps to what returns 200, not what you plan to build. If /de/widgets doesn't exist, de must not appear in that page's hreflang — full stop.


Mistake #3: the canonical that deindexes your translation

The subtle one. On /fr/widgets, what should <link rel="canonical"> point to?

The intuitive, wrong answer: the English master, /widgets. "It's the same page," right?

No. A canonical from /fr/widgets/widgets tells Google: "the French page is a duplicate; index the English one instead." You just asked Google to deindex your French page.

Canonical and hreflang do different jobs:

  • canonical = self-referential. /fr/widgets canonicals to /fr/widgets. ("This is the authoritative version of this URL.")
  • hreflang = declares the language siblings.

That's why the helper derives canonical from selfLocale, not from the English path.


How to verify (because nothing throws)

Every one of these is invisible to TypeScript, the build, and next dev. The only source of truth is the rendered HTML of a production build. So the test is a curl against next start, not a unit test:

next build && next start -p 3100 &

for p in es/widgets widgets fr/widgets; do
  curl -s "http://localhost:3100/$p" | node -e '
    let h=""; process.stdin.on("data",d=>h+=d).on("end",()=>{
      const canon=(h.match(/<link rel="canonical" href="([^"]+)"/i)||[])[1];
      const hl=[...new Set([...h.matchAll(/hreflang="([^"]+)"/gi)].map(m=>m[1]))];
      console.log(`/${p}\n  canonical: ${canon}\n  hreflang:  ${hl.join(", ")}`);
    });'
done
Enter fullscreen mode Exit fullscreen mode

The shape Google trusts: every page in the cluster reports the same hreflang set, and each canonical points at itself:

/es/widgets
  canonical: https://example.com/es/widgets
  hreflang:  en, fr, es, x-default
/widgets
  canonical: https://example.com/widgets
  hreflang:  en, fr, es, x-default
/fr/widgets
  canonical: https://example.com/fr/widgets
  hreflang:  en, fr, es, x-default
Enter fullscreen mode Exit fullscreen mode

Identical hreflang arrays, self canonicals. If your pages report different hreflang sets, you have mistake #1.


Bonus: write native, don't auto-translate

A hreflang setup is worthless if the pages behind it are machine-translated mush. Google's gotten good at detecting it, and readers feel it in two sentences. If a market matters enough to target, write the page natively. The technical wiring above is the easy 20%; native copy is the other 80%.

And if two of your localized pages are adjacent in meaning (say a "free" page and a "pro" page), differentiate them in every locale — or you've just exported keyword cannibalization into a new language.


Quick checklist

  • [ ] Every page's hreflang array is identical across its cluster (reciprocal)
  • [ ] Every hreflang URL returns 200 (no 404 / redirect / noindex targets)
  • [ ] Every localized page's canonical points at itself, not the English master
  • [ ] x-default points at your default (usually English)
  • [ ] You verified against the rendered <head> of a production build, not the source

See a reciprocal cluster in the wild

Theory is cheap — go curl a real one. These are live pages that share a reciprocal hreflang cluster, each with a self-referential canonical. Run the curl snippet above against any of them and you'll see the same hreflang line repeated across the set:

Notice the two clusters declare different locale sets — that's mistake #2 avoided in practice: each page only lists the languages it actually exists in.


I build PageStrike, a free AI landing page builder that ships pages in English, French, Spanish, German, Dutch, and Arabic — so this checklist is hard-won. If you handle the locale matrix differently (sitemap-driven or Edge-Config-driven instead of per-page args), I'd like to hear it in the comments — the per-page approach is clean at a handful of pages; I'm not sure it scales to seventy.

Top comments (0)