Next.js es la plataforma donde hreflang resulta más limpio: no necesitas plugin, módulo ni librería de SEO. El App Router lo cubre de forma nativa con el campo alternates.languages de generateMetadata. Lo que sí necesitas es tener claro qué URL corresponde a cada idioma, porque el framework se limita a imprimir lo que le pases y no valida nada.
La implementación básica con el App Router
Cualquier layout o page puede exportar generateMetadata. Lo que devuelvas en alternates.languages acaba como etiquetas de alternativa en el head. La clave es que el mismo objeto de idiomas debe ser idéntico en todas las versiones de la página, y debe incluir la propia.
// app/[locale]/servicios/page.tsx
import type { Metadata } from 'next'
const SITE = 'https://ejemplo.com'
const LOCALES = ['es', 'en', 'fr'] as const
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const languages = Object.fromEntries(
LOCALES.map((l) => [l, `${SITE}/${l}/servicios`]),
)
return {
alternates: {
canonical: `${SITE}/${locale}/servicios`,
languages: {
...languages,
'x-default': `${SITE}/es/servicios`,
},
},
}
}Como el objeto languages se construye recorriendo la lista completa de idiomas, la autorreferencia y la etiqueta de retorno salen gratis: las tres versiones declaran exactamente el mismo grupo. Es el motivo por el que este patrón falla mucho menos que escribir las etiquetas a mano.
En Next.js 15 y posteriores params es una promesa y hay que esperarla. Si copias un ejemplo antiguo que desestructura params directamente, TypeScript te avisará, pero en JavaScript fallará en silencio y acabarás con URLs que contienen "[object Promise]".
El caso del idioma por defecto sin prefijo
Muchos sitios sirven el idioma principal sin prefijo (ejemplo.com/servicios) y el resto con él (ejemplo.com/en/services). Es la configuración "as-needed" de next-intl. Aquí es donde se rompe la mayoría de implementaciones, porque el segmento del sistema de ficheros sigue siendo [locale] con valor "es" y resulta tentador construir la URL con él.
// Con localePrefix: 'as-needed' y defaultLocale: 'es'
const SITE = 'https://ejemplo.com'
function urlDe(locale: string, ruta: string) {
// El idioma por defecto NO lleva prefijo en la URL pública,
// aunque el segmento del router valga 'es'.
return locale === 'es' ? `${SITE}${ruta}` : `${SITE}/${locale}${ruta}`
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
return {
alternates: {
canonical: urlDe(locale, '/servicios'),
languages: {
es: urlDe('es', '/servicios'),
en: urlDe('en', '/servicios'),
'x-default': urlDe('es', '/servicios'),
},
},
}
}Si construyes la URL como `${SITE}/${locale}${ruta}` sin esa condición, la versión española declarará https://ejemplo.com/es/servicios. Esa URL normalmente redirige a la versión sin prefijo, y una etiqueta hreflang que apunta a una redirección es una etiqueta que Google trata como no válida.
Rutas traducidas
Si además traduces el slug —/servicios en español, /services en inglés— no puedes reutilizar la misma ruta para todos los idiomas. Necesitas un mapa explícito por página. next-intl lo resuelve con su configuración de pathnames, pero el principio es el mismo con cualquier enfoque: cada idioma tiene su propia ruta y el grupo hreflang debe reflejarlas.
const RUTAS = {
es: '/servicios',
en: '/services',
fr: '/prestations',
} as const
const languages = Object.fromEntries(
Object.entries(RUTAS).map(([l, ruta]) => [
l,
l === 'es' ? `${SITE}${ruta}` : `${SITE}/${l}${ruta}`,
]),
)Páginas dinámicas
En rutas con parámetros —fichas de producto, artículos— el grupo debe construirse a partir del contenido, no de la lista de idiomas. Si un artículo solo existe en dos de los tres idiomas, el grupo debe tener dos entradas. Declarar un idioma cuya URL no existe genera una etiqueta hacia un 404, y eso invalida el grupo completo.
Comprueba la existencia de la traducción antes de añadirla al objeto languages. Es más código, pero es la diferencia entre un grupo válido y uno que Google descarta entero.
Qué comprobar cuando ya está desplegado
En Next.js el error casi nunca está en la sintaxis: está en las URLs que has generado. Prefijos de más, rutas sin traducir, idiomas declarados que no existen para ese contenido. Como el framework imprime sin validar, el fallo solo se ve en el HTML servido, y solo comparando las versiones de un mismo grupo entre sí.
Prueba la herramienta gratis
Analiza tus URLs con Validador Hreflang de iRankly. Sin registro, sin tarjeta.
Si usas otra plataforma, o quieres el detalle conceptual de qué hace cada atributo: