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.

4 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.

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.

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.

Volver a todos los artículos