{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreic6u47bvvkdprfo34t5zhjp6bo2wuvetnykyxpitqae4fbcz2zeaa",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3moraletwdde2"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreickzjaiuepcnlitwiy7h77of7gfjop3xxxd335vtiolbyanirsutu"
    },
    "mimeType": "image/webp",
    "size": 140192
  },
  "path": "/youssefroop/hreflang-in-nextjs-16-3-mistakes-that-quietly-delete-your-translated-pages-from-google-ima",
  "publishedAt": "2026-06-21T01:00:46.000Z",
  "site": "https://dev.to",
  "tags": [
    "nextjs",
    "seo",
    "ai",
    "webdev",
    "English",
    "Spanish",
    "German",
    "Dutch",
    "French",
    "PageStrike",
    "free AI landing page builder"
  ],
  "textContent": "> **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.\n\nMultilingual 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.\n\nI'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.\n\n##  The shape: one helper, per-page locale sets\n\n`alternates.languages` in the Metadata API renders the `<link rel=\"alternate\" hreflang=\"...\">` tags for you. A tiny helper keeps it consistent:\n\n\n\n    // lib/hreflang.ts\n    type Locale = \"en\" | \"fr\" | \"es\" | \"de\" | \"nl\" | \"ar\";\n\n    /**\n     * availableLocales: ONLY the locales where a translated page actually exists.\n     * selfLocale: so the canonical is self-referential (the page points at itself,\n     *             never at the English master).\n     */\n    export function buildHreflang(\n      path: string,\n      availableLocales: readonly Locale[],\n      selfLocale: Locale = \"en\",\n      baseUrl = \"https://example.com\",\n    ) {\n      const suffix = path === \"/\" ? \"\" : path;\n      const languages: Record<string, string> = {};\n      for (const loc of availableLocales) {\n        languages[loc] = loc === \"en\" ? `${baseUrl}${path}` : `${baseUrl}/${loc}${suffix}`;\n      }\n      languages[\"x-default\"] = `${baseUrl}${path}`;\n\n      const canonical =\n        selfLocale === \"en\" ? `${baseUrl}${path}` : `${baseUrl}/${selfLocale}${suffix}`;\n\n      return { canonical, languages };\n    }\n\n\nUsed per page:\n\n\n\n    // app/es/widgets/page.tsx\n    export const metadata: Metadata = {\n      alternates: buildHreflang(\"/widgets\", [\"en\", \"fr\", \"es\"], \"es\"),\n    };\n\n\nThat `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:\n\nPage | Locales it exists in\n---|---\n`/widgets` | en, fr, es\n`/pricing` | en, fr\n`/guide` | en, fr, es, de, nl\n\nDon't translate every page into every language just to fill the grid. Translate the pages that match each market — and declare only those.\n\n##  Mistake #1: non-reciprocal hreflang (the cluster-killer)\n\nGoogle'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**.\n\nThe classic version: three pages in a cluster, two of them declare `[\"en\", \"fr\", \"es\"]`, and the third declares only `[\"en\", \"es\"]` — missing `fr`.\n\n\n\n    // app/es/widgets/page.tsx — BROKEN\n    buildHreflang(\"/widgets\", [\"en\", \"es\"], \"es\")\n    //                               ^^^^ missing \"fr\", but /fr/widgets exists and points here\n\n\n`/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.\n\nThe only reliable defense: **grep every hreflang declaration and confirm the locale arrays are identical across a cluster.** Same array, every page.\n\n##  Mistake #2: declaring hreflang to a URL that 404s\n\nWorse 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.\n\nThe trap is an aspirational \"supported locales\" list:\n\n\n\n    // A loaded gun\n    const SUPPORTED = [\"en\", \"fr\", \"es\", \"de\", \"nl\"]; // ...but /de/* doesn't exist yet\n\n\nThe day someone wires that into a sitemap or a language switcher, it emits `<link hreflang=\"de\">` tags to a wall of 404s.\n\nRule: **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.\n\n##  Mistake #3: the canonical that deindexes your translation\n\nThe subtle one. On `/fr/widgets`, what should `<link rel=\"canonical\">` point to?\n\nThe intuitive, wrong answer: the English master, `/widgets`. \"It's the same page,\" right?\n\nNo. 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.\n\nCanonical and hreflang do different jobs:\n\n  * **canonical** = self-referential. `/fr/widgets` canonicals to `/fr/widgets`. (\"This is the authoritative version of _this_ URL.\")\n  * **hreflang** = declares the language siblings.\n\n\n\nThat's why the helper derives canonical from `selfLocale`, not from the English path.\n\n##  How to verify (because nothing throws)\n\nEvery 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:\n\n\n\n    next build && next start -p 3100 &\n\n    for p in es/widgets widgets fr/widgets; do\n      curl -s \"http://localhost:3100/$p\" | node -e '\n        let h=\"\"; process.stdin.on(\"data\",d=>h+=d).on(\"end\",()=>{\n          const canon=(h.match(/<link rel=\"canonical\" href=\"([^\"]+)\"/i)||[])[1];\n          const hl=[...new Set([...h.matchAll(/hreflang=\"([^\"]+)\"/gi)].map(m=>m[1]))];\n          console.log(`/${p}\\n  canonical: ${canon}\\n  hreflang:  ${hl.join(\", \")}`);\n        });'\n    done\n\n\nThe shape Google trusts: every page in the cluster reports the **same** hreflang set, and each `canonical` points at **itself** :\n\n\n\n    /es/widgets\n      canonical: https://example.com/es/widgets\n      hreflang:  en, fr, es, x-default\n    /widgets\n      canonical: https://example.com/widgets\n      hreflang:  en, fr, es, x-default\n    /fr/widgets\n      canonical: https://example.com/fr/widgets\n      hreflang:  en, fr, es, x-default\n\n\nIdentical hreflang arrays, self canonicals. If your pages report _different_ hreflang sets, you have mistake #1.\n\n##  Bonus: write native, don't auto-translate\n\nA `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%.\n\nAnd 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.\n\n##  Quick checklist\n\n  * [ ] Every page's hreflang array is **identical** across its cluster (reciprocal)\n  * [ ] Every hreflang URL returns **200** (no 404 / redirect / noindex targets)\n  * [ ] Every localized page's canonical points at **itself** , not the English master\n  * [ ] `x-default` points at your default (usually English)\n  * [ ] You verified against the **rendered`<head>`** of a production build, not the source\n\n\n\n##  See a reciprocal cluster in the wild\n\nTheory 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:\n\n  * Free-plan cluster: English ↔ Spanish ↔ German ↔ Dutch\n  * A different locale set (same technique): English ↔ Spanish ↔ French\n\n\n\nNotice the two clusters declare _different_ locale sets — that's mistake #2 avoided in practice: each page only lists the languages it actually exists in.\n\n_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._",
  "title": "hreflang in Next.js 16: 3 mistakes that quietly delete your translated pages from Google"
}