Command Palette

Search for a command to run...

Comment ajouter Hreflang dans Next.js (routeur d'applications)

Comment ajouter Hreflang dans Next.js (routeur d'applications)

T
Toolz Team
|Aug 23, 2026|13 min Lire

Fait partie de la collection Outils de référencement

Toolz fonctionne sur le routeur d'application Next.js en neuf langues, et la configuration hreflang a connu trois mauvaises versions avant de tourner correctement. Le premier a mis une portée de module alternates objet dans la mise en page, qui ne peut pas varier selon les paramètres régionaux, donc chaque page traduite annonçait l'URL anglaise comme son canonique C'est le bug qui supprime les traductions : il indique à Google /es/pricing est un double de /pricing et ne doit pas être indexé, ce qui annule discrètement le travail de traduction alors que chaque page se rend toujours correctement.

La deuxième version corrigeait le canonique et annonçait chaque lieu configuré comme alternative, y compris les lieux dont les traductions n'avaient pas encore été générées. Le site a donc publié un groupe de hreflang nommant des pages qui étaient du texte anglais sous une URL espagnole. La troisième version, celle en cours maintenant, dérive l'ensemble alternatif de la couverture de traduction réelle, par page.

Ce guide parcourt la configuration de travail : la configuration locale, la generateMetadata implémentation, la variante du plan du site, et les trois erreurs ci-dessus pour que vous puissiez les sauter Il se trouve sous le guide complet de hreflang aux côtés de la Version WordPress.

tl;dr : Dans l'App Router, hreflang vient de alternates.languages retourné par generateMetadata(en), et le canonique doit être construit à partir de la locale active plutôt que déclaré une fois à la portée du module Construisez les deux à partir d'une carte locale, émettez l'ensemble réciproque complet sur chaque page du groupe et incluez x-default. Annoncez uniquement les lieux dont le contenu existe réellement, ou vous publiez un cluster pointant vers des pages non traduites. Le générateur hreflang est utile pour vérifier la sortie rendue par rapport à un ensemble de références validé.

Que vous donne Next.js hors de la boîte ?

L'API Metadata prend directement en charge hreflang. Retour alternates.languages de generateMetadata produit le <link rel="alternate" hreflang> étiquettes dans la tête :

export async function generateMetadata({ params }) {
  const { locale } = await params
  return {
    alternates: {
      canonical: 'https://example.com/de/preise',
      languages: {
        'en-US': 'https://example.com/pricing',
        'de-DE': 'https://example.com/de/preise',
        'x-default': 'https://example.com/pricing',
      },
    },
  }
}

Next.js les rend dans la tête et gère le x-default clé sans casse spécial, comme son Référence de l'API des métadonnées documents. Ce qu'il ne fait pas, c'est décider quelles localités appartiennent à l'ensemble, garder le canonique synchronisé ou vous empêcher de placer l'objet entier à la portée du module où il ne peut pas varier. Ce sont les parties que vous devez obtenir correctement, et ce sont les parties qui se brisent.

Notez aussi que metadataBase affecte ici les URL relatives. Hreflang nécessite des URL absolues, donc l'un ou l'autre défini metadataBase et utilisez des chemins, ou construisez vous-même des URL absolues Je préfère les construire explicitement à partir d'une origine configurée, car un déploiement d'aperçu qui émet des URL de production est sa propre catégorie de problème.

Étape 1 : une carte locale et une seule

Tout ce qui se lit en aval de ceci Un lieu a besoin de trois faits : le segment de route, le tag BCP 47 pour hreflang et <html lang>2, et s'il est actuellement expédié.

// i18n/locales.ts
export const DEFAULT_LOCALE = 'en'

export const LOCALES = [
  { code: 'en', hreflang: 'en-US', label: 'English' },
  { code: 'de', hreflang: 'de-DE', label: 'Deutsch' },
  { code: 'fr', hreflang: 'fr-FR', label: 'Français' },
  { code: 'ja', hreflang: 'ja-JP', label: '日本語' },
]

/** '/pricing' -> '/de/pricing', and '/pricing' for the default locale. */
export function localize(path: string, locale: string): string {
  return locale === DEFAULT_LOCALE ? path : `/${locale}${path}`
}

le code et le hreflang la valeur sont des champs délibérément séparés Le segment de route est de parce que personne ne veut /de-DE/pricing dans leurs URL, et la valeur hreflang est de-DE parce que c'est ce que vous voulez annoncer Les confondre signifie soit des URL laides, soit un nu de dans l'annotation, et au moment où vous ajoutez pt-BR aux côtés pt-PT l’amalgame cesse de fonctionner.

Étape 2 : une aide qui construit l'ensemble

Une fonction, utilisée par chaque page, pour que la forme ne puisse pas dériver entre les itinéraires :

export function hreflangAlternates(path: string, locales: string[], base = SITE_URL) {
  const eligible = LOCALES.filter((l) => locales.includes(l.code))
  // A single-entry cluster is not a cluster: emit nothing rather than
  // advertise a one-sided relationship.
  if (eligible.length < 2) return []

  const alts = eligible.map((l) => ({
    hreflang: l.hreflang,
    href: base + localize(path, l.code),
  }))
  alts.push({ hreflang: 'x-default', href: base + localize(path, DEFAULT_LOCALE) })
  return alts
}

Deux décisions là-dedans valent la peine d'être volées.

Le retour anticipé sur moins de deux lieux. Une page qui existe uniquement en anglais ne devrait émettre aucun hreflang. Une seule annotation autoréférentielle n'est pas exactement fausse, mais c'est du bruit, et la version de ce code qui l'a émis a fait ressembler chaque page uniquement en anglais à un cluster brisé dans les rapports d'exploration.

x-default est ajouté par l'assistant et non par l'appelant. Chaque implémentation que j'ai vue qui laisse le repli à l'appelant se retrouve avec un modèle qui l'a oublié Pliez-le dans la chose qui construit l'ensemble et il ne peut pas être oublié.

Étape 3 : connectez-le à generateMetadata

// app/[locale]/pricing/page.tsx
import { localize, hreflangAlternates, SHIPPING_LOCALES } from '@/i18n/locales'
import { SITE_URL } from '@/lib/site'

export async function generateMetadata({ params }) {
  const { locale } = await params
  const path = '/pricing'

  const alternates = hreflangAlternates(path, SHIPPING_LOCALES)

  return {
    alternates: {
      // Built from the ACTIVE locale. This is the line that matters.
      canonical: `${SITE_URL}${localize(path, locale)}`,
      ...(alternates.length
        ? { languages: Object.fromEntries(alternates.map((a) => [a.hreflang, a.href])) }
        : {}),
    },
  }
}

Le canonique est la ligne à regarder Ça doit être fonction de locale(en), ce qui signifie qu'il ne peut pas vivre dans une constante module-portée, ne peut pas vivre dans la disposition racine, et ne peut pas être partagé à travers le segment local Chaque page du groupe se retrouve avec une identique languages carte et un canonique unique à lui-même, qui est exactement la forme hreflang et canonique exiger.

Parce que la carte est identique dans tout le groupe, la réciprocité est satisfaite structurellement plutôt que par discipline La page allemande répertorie l'allemand, l'anglais, le français, le japonais et le x par défaut ; la page anglaise aussi Il n'y a pas de logique par page qui pourrait omettre une entrée.

Étape 4 : ne faites que de la publicité pour ce qui existe

C'est l'étape que la plupart des guides sautent, et c'est celle qui a produit la pire version de notre configuration.

Si vos traductions sont générées de manière asynchrone, ou qu'une locale est à moitié peuplée, la liste de paramètres régionaux configurée et le contenu traduit sont des ensembles différents La publicité de la liste configurée signifie la publication de hreflang pour les pages qui sont du texte anglais sous une URL localisée Google suit l'annotation, trouve l'anglais là où l'allemand a été promis, et vous avez fabriqué du contenu en double sur neuf lieux à la fois.

Le correctif est de passer la couverture réelle plutôt que la configuration :

// Coverage is per-page, not global: this page might be translated
// into three languages while the one next to it has none.
const locales = await localesForPage(path)
const alternates = hreflangAlternates(path, locales)

Associez ça à robots: { index: false, follow: true } sur les pages qui existent dans une locale mais qui n'ont pas encore de copie traduite Elles restent joignables pour tous ceux qui arrivent de votre propre navigation, et elles restent en dehors de l'index jusqu'à ce que la copie atterrit Les deux tombent automatiquement une fois la couverture remplie, sans déploiement de suivi.

Étape 5 : la variante du plan du site

Si vous préférez garder hreflang hors des têtes de page, le même assistant alimente un itinéraire de plan de site Un <loc> par élément de contenu avec des suppléants comme enfants, plutôt qu'un <loc> par lieu :

// app/sitemap-pages.xml/route.ts
const entries = PAGES.map((path) => {
  const alts = hreflangAlternates(path, SHIPPING_LOCALES)
  const links = alts
    .map((a) => `\n    <xhtml:link rel="alternate" hreflang="${a.hreflang}" href="${a.href}"/>`)
    .join('')
  return `  <url>\n    <loc>${SITE_URL}${path}</loc>${links}\n  </url>`
})

const xml =
  `<?xml version="1.0" encoding="UTF-8"?>\n` +
  `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" ` +
  `xmlns:xhtml="http://www.w3.org/1999/xhtml">\n${entries.join('\n')}\n</urlset>`

le xmlns:xhtml déclaration sur <urlset> est obligatoire et facile à oublier ; sans cela, les entrées alternatives sont ignorées. Notez la structure : en répertoriant une <loc> per locale multiplie le fichier par le nombre de paramètres régionaux et met chaque traduction en concurrence avec son propre canonique Même couverture de découverte, une fraction des entrées.

Choisissez une méthode Si vous émettez à la fois des balises principales et des entrées de plan de site pour les mêmes URL, vous avez deux sources qui dériveront. Nos balises principales pour les pages et les alternatives de plan de site pour le catalogue d'outils, qui sont des ensembles disjoints.

La locale par défaut reçoit-elle un préfixe d'URL ?

Il s'agit d'une décision de routage qui modifie chaque URL de votre ensemble hreflang, alors prenez-la avant d'écrire l'assistant plutôt qu'après.

Deux stratégies sont courantes. Préfixe nécessaire sert la locale par défaut non préfixée à /pricing et tout le reste à /de/pricing. Préfixation toujours dessert chaque localité sous un segment, y compris /en/pricing, avec /pricing redirection. Le middleware Next.js prend en charge les deux, et next-intl l'expose comme un localePrefix réglage.

Selon les besoins (/pricing, /de/pricing) Toujours (/en/pricing, /de/pricing)
URL anglaises existantes Conservé Chacun devient une redirection
hreflang pour la locale par défaut Points à l'URL non préfixée Points à /en/...
cible x-par défaut La racine non préfixée, qui est aussi la en ouverture Doit choisir une localité préfixée
Symétrie dans le code localize() a besoin d'une branche de localisation par défaut Pas de branche ; chaque lieu est uniforme

J'utilise le préfixage selon les besoins sur un site existant, car réécrire chaque URL anglaise indexée pour gagner en symétrie est un coût important pour un petit gain de rangement, et les redirections sur vos pages les plus fréquentées ne sont pas gratuites. Sur une construction vierge, le préfixage toujours est plus propre : le localize helper perd son cas particulier, et il n'y a jamais de question à savoir si /pricing et /en/pricing sont la même page.

Ce qui compte pour hreflang dans un sens ou dans l'autre, c'est la cohérence L'URL dans l'annotation, l'URL dans le canonique et l'URL qui sert un 200 sans redirection doivent être la même chaîne La seule combinaison qui se casse de manière fiable est toujours préfixée avec hreflang pointant toujours vers les URL non préfixées : chaque entrée pour la locale par défaut nomme alors une redirection, et la balise de retour vit sur la destination plutôt que sur l'URL que vous avez nommée.

Quelle bibliothèque utiliser ?

La plupart des configurations App Router i18 n se retrouvent sur next-intl ou next-i18next(en), et ni l'un ni l'autre ne génère de hreflang pour vous Ils résolvent le routage et le chargement des messages ; les annotations sont toujours à vous d'émettre C'est bien, car l'assistant ci-dessus fait vingt lignes et vous le voulez de toute façon sous votre propre contrôle.

Préoccupation Géré par la bibliothèque Le vôtre
Routage local et middleware oui
Catalogues de messages et repli oui
<html lang> habituellement Vérifiez qu'il correspond à la valeur hreflang
alternates.canonical par lieu non Construire à partir du lieu actif
alternates.languages non Construire à partir de la carte locale
x-default non Ajouter dans l'assistant

La seule chose spécifique à la bibliothèque à vérifier est <html lang>. Les données structurées et les hreflang qui contredisent le langage des documents sont pires que rien, ainsi qu'une mise en page que les codes durs lang="en" alors qu'il sert l'allemand est un reste étonnamment courant.

Comment le vérifiez-vous ?

Construire et lire le résultat réel, car le modèle n'est pas la preuve :

next build && next start
curl -s http://localhost:3000/de/preise | grep -E 'rel="(canonical|alternate)"'

Trois vérifications sur cette sortie Le canonique est l'URL que vous avez récupérée Il y a exactement une entrée par locale plus une x-default. Chaque href est absolu et correspond à l'URL à laquelle cette page est servie, barre oblique de fin incluse.

Puis récupérez un frère ou une sœur et diffez les deux languages blocs. Ils devraient être octet-identiques ; seul le canonique diffère S'ils ne sont pas identiques, quelque chose dans votre page est de construire l'ensemble à partir du lieu actuel plutôt que du groupe, qui est l'échec de réciprocité dans son déguisement le plus courant.

Enfin, collez l'ensemble dans le générateur d'étiquettes Hreflang pour valider les codes eux-mêmes Il vérifie chaque valeur par rapport à la forme ISO, signale les sous-étiquettes non reconnues, attrape les doublons et avertit lorsqu'un ensemble n'a pas de repli Il s'exécute côté client, donc une structure d'URL de mise en scène reste privée Le guide d'outils parcourt le flux de travail, et 12 erreurs de hreflang courantes couvre ce qu'il faut rechercher une fois le balisage en ligne.

Questions fréquentes

Comment ajouter des balises hreflang dans le routeur d'applications Next.js ?

Renvoyer un alternates.languages objet de generateMetadata: mapper chaque balise BCP 47 à son URL absolue et définir alternates.canonical de la localité active. Next.js rend les deux dans la tête.

Pourquoi le canonique doit-il être construit à l'intérieur de generateMetadata ?

Parce qu'il doit varier selon les paramètres régionaux. Un canonique module-scope ne le peut pas, donc chaque page traduite déclarerait l'URL de la plage par défaut comme canonique, ce qui supprime la traduction de l'index.

Est-ce que next-intl génère automatiquement des balises hreflang ?

Le n° next-intl gère le routage et le chargement des messages Les annotations hreflang et le canonique par locale sont toujours à vous d'émettre generateMetadata.

Comment ajouter x-default dans Next.js ?

Ajouter un 'x-default' clé de la alternates.languages objet pointant vers votre URL de secours Next.js passe la clé sans manipulation spéciale. Ajoutez-la à l'intérieur de l'assistant qui construit l'ensemble afin qu'aucun modèle ne puisse l'oublier.

Le hreflang doit-il entrer dans la tête ou sur le plan du site dans Next.js ?

Soit fonctionne, mais pas les deux pour les mêmes URL. Head tags via generateMetadata sont plus faciles à déboguer Un itinéraire de plan de site convient aux grands catalogues et maintient les têtes de page maigres ; rappelez-vous le xmlns:xhtml déclaration sur <urlset>.

Et si un lieu n'est que partiellement traduit ?

Annoncez uniquement les lieux dont le contenu existe réellement pour cette page et marquez les pages non traduites noindex, follow. La publicité de la liste de paramètres régionaux configurés publie un cluster pointant vers des pages non traduites.

Les URL hreflang dans Next.js doivent-elles être absolues ?

Oui. Hreflang nécessite des URL entièrement qualifiées. Soit défini metadataBase ou créez les URL à partir d'une origine configurée afin que les déploiements d'aperçus n'émettent pas d'URL de production.

Comment puis-je vérifier la sortie hreflang rendue ?

Exécutez une version de production, récupérez deux pages sœurs et comparez. Le languages les blocs doivent être identiques aux octets dans tout le groupe tandis que chaque canonique pointe vers sa propre URL.


Comments

0 comments

0/2000 characters

No comments yet. Be the first to share your thoughts!