Saltar al contenido
Milian Lastre ZequeiraES
nextjs2 min

hreflang bilingüe correcto en Next.js 16 con next-intl

Cómo dejar hreflang bien puesto en Next.js 16 con next-intl, sin que el sitemap diga otra cosa.

— MLZnextjsi18nseo

El hreflang mal hecho es silencioso: no rompe la página, solo te cuesta rankings. Google exige que las anotaciones sean recíprocas (si ES apunta a EN, EN debe apuntar a ES) e idealmente con un x-default. En un sitio bilingüe hecho con Next.js 16 App Router y next-intl v4, así lo resuelvo de raíz.

1. Ruteo: URLs localizadas, carpetas no

El error más común es renombrar carpetas al idioma. En su lugar, dejo los nombres de carpeta en un idioma y mapeo las URLs con pathnames:

// i18n/routing.ts
export const routing = defineRouting({
  locales: ["es", "en"],
  defaultLocale: "es",
  localePrefix: { mode: "as-needed" }, // ES sin prefijo, EN con /en
  pathnames: {
    "/sobre-mi": { es: "/sobre-mi", en: "/about" },
    "/blog/[slug]": { es: "/notas/[slug]", en: "/writeups/[slug]" },
  },
});

En Next.js 16 el middleware se llama proxy.ts (rename respecto a 15). Ahí vive el ruteo de next-intl:

// proxy.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "@/i18n/routing";
export default createMiddleware(routing);

2. Enlaces internos sin fugas

Dentro de app/[locale]/ uso siempre el Link envuelto de next-intl, no el de next/link. Así el prefijo de locale y el pathname traducido se resuelven solos:

// i18n/navigation.ts
export const { Link } = createNavigation(routing);

3. hreflang por página, emparejado por translationKey

Cada contenido lleva un translationKey idéntico en su par ES/EN. Con eso construyo alternates.languages en generateMetadata, incluyendo el x-default:

// app/[locale]/blog/[slug]/page.tsx
const languages: Record<string, string> = {};
if (post.locale === "es") languages.es = `/blog/${post.slug}`;
if (post.locale === "en") languages.en = `/en/blog/${post.slug}`;
if (translation) {
  if (translation.locale === "es") languages.es = `/blog/${translation.slug}`;
  if (translation.locale === "en") languages.en = `/en/blog/${translation.slug}`;
}
languages["x-default"] = languages.es ?? `/${locale}/blog/${post.slug}`;
 
return { alternates: { canonical: path, languages } };

Next.js emite esto como <link rel="alternate" hreflang="es" …> recíproco. La clave es que ambas direcciones existan: la reciprocidad la garantiza el emparejamiento por translationKey, no una lista escrita a mano.

4. El sitemap debe contar la misma historia

hreflang en el <head> y en el sitemap tienen que coincidir. En sitemap.ts repito los alternates.languages por entrada:

alternates: {
  languages: { es: urlEs, en: urlEn, "x-default": urlEs },
}

Checklist

  • Reciprocidad garantizada por datos (translationKey), no a mano.
  • x-default presente en cada grupo.
  • URLs absolutas y canónicas estables por locale.
  • <head> y sitemap consistentes.

Con esto, un post sin par de traducción degrada con gracia (se auto-referencia) en vez de emitir un hreflang roto — y el build sigue verde.

Construyamos algo propio

Hablemos