惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

A
About on SuperTechFans
G
Google Developers Blog
L
LangChain Blog
aimingoo的专栏
aimingoo的专栏
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
云风的 BLOG
云风的 BLOG
小众软件
小众软件
月光博客
月光博客
Recent Announcements
Recent Announcements
人人都是产品经理
人人都是产品经理
P
Proofpoint News Feed
博客园 - 聂微东
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
雷峰网
雷峰网
The Cloudflare Blog
博客园_首页
美团技术团队
大猫的无限游戏
大猫的无限游戏
B
Blog
IT之家
IT之家
Jina AI
Jina AI
H
Hackread – Cybersecurity News, Data Breaches, AI and More
C
Check Point Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
hreflang in Next.js 16: 3 mistakes that quietly delete yo...
Youssefroop · 2026-06-21 · via DEV Community

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 };
}

Used per page:

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

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

/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

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

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

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.