Saltar al contenido

hreflang correcto en Next.js - generado, no mantenido

La mayoría de los sitios Next.js multilingües publican un clúster de hreflang roto. El fallo es siempre el mismo - las etiquetas se escriben a mano.

10 min de lectura

La especificación de hreflang es corta y sus reglas son simples. Casi todas las implementaciones que auditamos siguen equivocándose, y casi siempre por la misma causa raíz: alguien mantiene las alternativas a mano, las traducciones se mueven, y las etiquetas no.

Las tres reglas que se incumplen

1. Cada página de un clúster debe listar todas las páginas del clúster, incluida ella misma. hreflang es bidireccional. Si tu página en inglés apunta a la alemana pero la alemana no devuelve el apunte, Google descarta la relación. La autorreferencia no es opcional.

2. No anuncies una traducción que no existe. Hacer que hreflang="de" apunte a una página en inglés porque la versión alemana «llegará pronto» es peor que omitirla. Le estás diciendo al rastreador que los germanohablantes tienen una página en alemán, y luego los decepcionas.

3. x-default es para el respaldo, no para el inglés. Marca la página que se sirve a usuarios cuyo idioma no persigues - normalmente tu selector de idioma, o tu idioma por defecto si no tienes selector.

Por qué las etiquetas mantenidas a mano siempre se pudren

Piensa en el ciclo de vida. Lanzas en inglés y alemán. Alguien escribe los metadatos:

// The version that will be wrong within a month
alternates: {
  languages: {
    en: 'https://example.com/pricing',
    de: 'https://example.com/de/pricing',
  },
}

Luego se lanza el español, pero solo para las páginas de marketing. Luego se despublica un artículo alemán. Luego alguien renombra un slug. Cada una de esas cosas es una pull request distinta, en otra parte del código, y ninguna toca este objeto.

Seis meses después Search Console informa de «sin etiquetas de retorno» en unos cientos de URL y nadie puede reconstruir por qué.

Genera desde el contenido, no desde una lista

La solución es convertir las alternativas en un valor derivado. En el código de este sitio - que es público - la capa de contenido responde a una pregunta:

/**
 * 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`)),
  );
}

Y el ayudante de metadatos la consume:

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 },
  // ...
};

Borra un fichero de traducción y la etiqueta desaparece en el siguiente build. Añade uno y aparece. No hay una lista que se te olvide actualizar, porque no hay lista.

La autorreferencia sale gratis: el idioma actual está en availableLocales por definición, así que siempre se emite.

El fallo que destruye un clúster: canonical en desacuerdo con hreflang

Todo lo anterior da por supuesto que cada página es canónica de sí misma. Cuando no lo es, nada de esto funciona, y es el error más dañino que encontramos.

El patrón parece razonable en un pull request. Alguien se preocupa por el contenido duplicado entre idiomas y apunta el canonical de cada idioma al original en inglés:

// Cada página traducida está pidiendo ahora no ser indexada
alternates: {
  canonical: 'https://example.com/pricing',
  languages: { en: '...', de: '...', es: '...' },
}

Un canonical significa «indexa esta otra URL en lugar de a mí». Así que la página alemana está pidiendo que la descarten, la española también, y los alternates apuntan a páginas que se han dado de baja del índice. El clúster se colapsa en una sola página, y el síntoma - páginas traducidas que nunca aparecen en los resultados de su propio idioma - no se parece en nada a su causa.

Las traducciones no son contenido duplicado. Cada idioma es canónico de sí mismo, y hreflang es lo que le dice al rastreador que son versiones unas de otras. Esa es toda la división del trabajo entre las dos etiquetas.

Las redirecciones automáticas, y por qué el rastreador puede no ver tus otros idiomas

El segundo fallo estructural no está en las etiquetas. Es un middleware que lee Accept-Language y redirige.

Googlebot rastrea predominantemente desde direcciones de Estados Unidos con un Accept-Language de en-US, y no lo varía para descubrir qué más tienes. Si una petición a /es/precios se redirige a /pricing porque el rastreador no pidió español, entonces la página en español no existe para el índice, y cada etiqueta hreflang que apunta a ella apunta a una redirección.

La regla es que la negociación de idioma puede sugerir, nunca obligar:

  • Redirige solo desde una raíz desnuda que no tiene idioma propio, nunca desde una URL que ya nombra uno.
  • Haz que la elección persista en una cookie, para no pelearte en cada navegación con quien sobrescribió la suposición.
  • Ofrece la alternativa como un aviso que se pueda cerrar en lugar de una redirección, siempre que puedas.

Un prefijo de idioma presente en la URL es una declaración de intención, de una persona o de un rastreador. Sobrescribirla es como se acaba con tres idiomas que nadie fuera de la oficina ha llegado a ver.

Cuando solo algunas páginas están traducidas

La cobertura parcial es el estado normal de un sitio real, y no es un error. El error es tratar el clúster como una propiedad del sitio y no de la página.

El sitio soporta cuatro idiomas; este artículo concreto existe en dos. Su clúster tiene dos entradas. El artículo de al lado tiene cuatro. Los dos son correctos, y los dos se calculan en lugar de declararse, que es toda la razón por la que el enfoque derivado de arriba compensa.

El único caso que exige una decisión es una página que existe en un idioma pero sin traducir: publicada en inglés dentro de una URL con prefijo español porque la navegación necesita que la ruta resuelva. O la traduces o la dejas fuera del build en español. Lo que no deberías hacer es servir inglés en /es/... y etiquetarlo hreflang="es".

noindex y hreflang no se mezclan

Una página con noindex no puede participar en un clúster. Se descarta, y como hreflang es bidireccional, su ausencia debilita la reciprocidad de todas las páginas que apuntaban a ella.

Esto muerde en dominios de staging y en despliegues de previsualización que llevan metadatos de producción. Si un entorno de preview sirve los mismos alternates que producción, estás anunciando un clúster cuyos miembros responden con noindex. Sirve Disallow: / y un noindex en las previews, y deriva el origen de los metadatos desde el despliegue en lugar de fijarlo de forma que las previews lo hereden.

Códigos de idioma, y cuándo añadir una región

Usa el código de idioma a secas salvo que de verdad sirvas contenido distinto a regiones distintas:

  • de - germanohablantes en cualquier lugar.
  • de-AT - solo si los visitantes austríacos reciben precios, existencias o textos legales distintos de los alemanes.

Un código de región que no puedes justificar reparte tus señales entre dos clústeres sin ningún beneficio. Las excepciones habituales son reales: en-GB frente a en-US por ortografía y moneda, es-MX frente a es-ES por vocabulario y precios, pt-BR frente a pt-PT.

La región nunca es por sí sola un mecanismo de segmentación por país. hreflang="de" no significa «mostrar en Alemania»; significa «esta página es para germanohablantes».

Árabe y otros idiomas RTL

Los idiomas de derecha a izquierda no necesitan nada especial de hreflang - ar es un código de idioma como cualquier otro. Lo que sí necesitan es un dir correcto en el documento, y eso tiene que ser por idioma:

export default async function LocaleLayout({ children, params }) {
  const { locale } = await params;
 
  return (
    <html lang={locale} dir={dirFor(locale)}>
      {/* ... */}
    </html>
  );
}

Después escribe tu maquetación con propiedades lógicas de CSS - padding-inline-start en lugar de padding-left, margin-inline-end en lugar de margin-right, text-align: start en lugar de left. Las utilidades ps-*, pe-*, ms-* y me-* de Tailwind se corresponden exactamente con ellas. Hazlo desde el principio y la maquetación árabe se refleja sola; adáptalo después y pasarás una semana buscando los doce sitios que no lo hicieron.

Las excepciones que no deben reflejarse: bloques de código, direcciones de correo, URL y nombres de marca en alfabeto latino. Ponles direction: ltr explícitamente.

Cómo verificarlo

Tres comprobaciones, en orden creciente de confianza:

  1. Ver el código fuente. No el inspector - el inspector muestra el DOM ya hidratado. curl -s https://example.com/de/pricing | grep alternate muestra lo que recibe el rastreador.
  2. Search Console → Segmentación internacional. Informa de errores «sin etiquetas de retorno» y «código de idioma desconocido» en toda la propiedad.
  3. Rastréalo. Screaming Frog o Sitebulb trazan el clúster completo y te dicen a qué páginas les faltan etiquetas recíprocas. Eso atrapa los casos en que las etiquetas son válidas de una en una pero el grafo está incompleto.

Hay una cuarta, y es la única que atrapa el fallo antes de publicarlo. Esta web descarga cada URL de su propio sitemap en cada push y falla el build cuando una página falta en su propio clúster hreflang, cuando un canonical está ausente o apunta a otro sitio, o cuando una forma de URL duplicada responde en lugar de redirigir. Un clúster generado elimina la clase de error en la que alguien se olvida de actualizar una lista; una comprobación en CI elimina la clase en la que la generación misma está mal.

El sitemap tiene que estar de acuerdo

hreflang puede vivir en el head del HTML o en el sitemap. Si emites ambos - y deberías - tienen que coincidir exactamente, porque una discrepancia entre ellos se resuelve de forma impredecible.

Aplica el mismo principio: genera el sitemap desde el mismo ayudante que usan las páginas. Lo tratamos, junto con la corrección de las URL canónicas, en canónicas y sitemaps que no pueden derivar.

Preguntas relacionadas

¿Le dice hreflang a Google en qué país mostrar la página?
No. Declara el idioma para el que está escrita una página, y opcionalmente la región a la que sirve esa variante. La segmentación por país es una señal aparte. Una página marcada con hreflang="es" se ofrece a hispanohablantes estén donde estén, no a visitantes en España.
Nuestro canonical apunta a la página en inglés en todos los idiomas. ¿Es un problema?
Es el fallo que destruye un clúster entero. Un canonical dice «indexa esta otra URL en mi lugar», así que cada página traducida está pidiendo no ser indexada. Los alternates apuntan entonces a páginas que se han dado de baja. Cada idioma tiene que ser canónico de sí mismo.
¿hreflang va en el head o en el sitemap?
Cualquiera de los dos vale, y los dos a la vez está bien mientras coincidan exactamente. Una discrepancia entre ambos se resuelve de forma impredecible, que es peor que emitir solo uno. Genéralos desde la misma fuente y no podrán discrepar.
Solo hemos traducido la mitad del sitio. ¿Sigue siendo válido un clúster parcial?
Sí, y es el caso normal. Un clúster es el conjunto de idiomas en los que existe una página concreta, no el conjunto que soporta el sitio. El fallo no es la cobertura parcial: es anunciar una traducción que nunca se escribió.

← Volver a todos los artículos

Llamar+1 848 272 7583WhatsApp+90 850 308 5436Correoinfo@codefacture.comPágina de contacto