Fredy Acuna
  • Publicaciones
  • Proyectos
  • Herramientas
  • Contacto
LinkedInXGitHubMedium

© 2026 Fredy Acuna

Volver a publicaciones
No Redirijas a los Crawlers por Idioma: Locale en la URL y hreflang con el Proxy de Next.js 16

No Redirijas a los Crawlers por Idioma: Locale en la URL y hreflang con el Proxy de Next.js 16

Fredy Acuna / October 30, 2026 / 7 min read

El Problema: Páginas en Español Que los Buscadores Nunca Ven

Mi portafolio tiene seis páginas con una URL en español y una hermana en inglés:

  • /publicaciones y /posts
  • /proyectos y /projects
  • /contacto y /contact
  • /privacidad y /privacy
  • /herramientas y /tools
  • /tools/distribucion-financiera y /tools/financial-distribution

Las versiones en español existían, se veían bien en mi navegador y estaban en el sitemap. Pero ninguna se estaba indexando. La causa era una detección de idioma "inteligente" que escribí meses antes.


Lo Que Vas a Aprender

  • Por qué las redirecciones por idioma basadas en Accept-Language esconden páginas a los crawlers
  • Qué recomienda Google en su lugar
  • Cómo reemplazar middleware.ts por la convención proxy.ts de Next.js 16
  • Cómo dejar que la URL decida el idioma de la interfaz sin redirigir a nadie
  • Una nota sobre páginas 404 que responden HTTP 200

El Middleware Anterior

La estrategia, en palabras del propio middleware, era "estilo Amazon". Si el visitante tenía una cookie de idioma, se respetaba. Si no, se leía Accept-Language y se redirigía a la página hermana:

function detectAcceptLanguage(header: string | null): Locale {
  if (!header) return 'en'
  const primary = header.split(',')[0]?.trim().toLowerCase() ?? ''
  return primary.startsWith('es') ? 'es' : 'en'
}

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl
  const pair = PAGE_PAIRS[pathname]
  if (!pair) return NextResponse.next()

  // Honor explicit cookie — user already chose
  const cookieLocale = req.cookies.get(LOCALE_COOKIE)?.value
  if (cookieLocale === 'es' || cookieLocale === 'en') {
    return NextResponse.next()
  }

  // No cookie yet → use Accept-Language as the hint
  const detected = detectAcceptLanguage(req.headers.get('accept-language'))
  const targetPath = pair[detected]
  if (pathname === targetPath) {
    return NextResponse.next()
  }

  const url = req.nextUrl.clone()
  url.pathname = targetPath
  return NextResponse.redirect(url)
}

Para una persona con el navegador en español funcionaba bien. Ahora míralo desde el punto de vista de un crawler.


Por Qué los Crawlers Chocaban Contra la Pared

Crawlers como Googlebot y Bingbot normalmente no envían el encabezado Accept-Language y no conservan cookies entre peticiones. Así que para ellos:

  1. No hay cookie, así que se salta la primera rama
  2. No hay Accept-Language, así que detectAcceptLanguage devuelve 'en'
  3. /publicaciones no es la ruta en inglés, así que redirige (307) a /posts

Cada URL en español redirigía a su hermana en inglés, siempre. Desde el lado del buscador, las páginas en español simplemente no existían como documentos indexables.

La guía de Google para sitios multilingües dice justamente eso: evita redirigir automáticamente según el idioma que supones del usuario, porque puede impedir que usuarios y crawlers lleguen a todas las versiones de tu sitio. Usa hreflang para indicar qué URLs son traducciones entre sí y deja que las personas cambien de idioma con un enlace visible.


El Middleware Ahora Es Proxy

Mientras arreglaba esto, el build de Next.js 16 me recordó otra cosa:

The "middleware" file convention is deprecated. Please use "proxy" instead.

Next.js 16 renombró middleware.ts a proxy.ts, y la función exportada de middleware a proxy. La misma idea con otro nombre. Así que la corrección también fue un buen momento para renombrar el archivo.


El Nuevo Proxy: Nunca Redirige, Solo Le Avisa a la App

La nueva regla es simple: la URL decide el idioma. /publicaciones es español y /posts es inglés, para todos, bots incluidos. Sin redirecciones.

El proxy solo agrega a la petición un encabezado x-locale que la aplicación puede leer después:

import { NextResponse, type NextRequest } from 'next/server'
import { PAGE_PAIRS, type Locale } from '@/lib/locale-routes'

/**
 * Pins the UI locale to the URL on locale-specific routes.
 *
 * No redirects: crawlers send no Accept-Language, so redirecting them to the
 * sibling page made the Spanish URLs unindexable. Visitors switch language
 * with the toggle; hreflang tells search engines about the pair.
 */

const CONTENT_PREFIXES: Record<string, Locale> = {
  '/posts/': 'en',
  '/projects/': 'en',
  '/publicaciones/': 'es',
  '/proyectos/': 'es',
}

function pathLocale(pathname: string): Locale | null {
  const pair = PAGE_PAIRS[pathname]
  if (pair) {
    if (pair.es === pair.en) return null
    return pathname === pair.es ? 'es' : 'en'
  }
  for (const [prefix, locale] of Object.entries(CONTENT_PREFIXES)) {
    if (pathname.startsWith(prefix)) return locale
  }
  return null
}

export function proxy(req: NextRequest) {
  const locale = pathLocale(req.nextUrl.pathname)
  if (!locale) return NextResponse.next()

  const requestHeaders = new Headers(req.headers)
  requestHeaders.set('x-locale', locale)
  return NextResponse.next({ request: { headers: requestHeaders } })
}

Dos detalles que vale la pena señalar:

  • NextResponse.next({ request: { headers } }) envía una petición modificada a la aplicación. El encabezado no llega al navegador; solo existe para el código de servidor que se ejecuta durante esa petición.
  • Las páginas que son iguales en ambos idiomas (como /) devuelven null, así que siguen usando la cookie o el idioma del navegador.

Las publicaciones y proyectos individuales también quedan cubiertos gracias a los prefijos de ruta (/publicaciones/... siempre es español).


Leer el Idioma en la Aplicación

En el servidor, getLocale() ahora revisa primero el encabezado. El orden es: URL, luego cookie y luego Accept-Language:

export async function getLocale(): Promise<Locale> {
  // Set by proxy.ts on locale-specific URLs; the URL beats the cookie.
  const h = await headers()
  const pathLocale = h.get('x-locale')
  if (pathLocale === 'es' || pathLocale === 'en') return pathLocale

  const cookieStore = await cookies()
  const cookieVal = cookieStore.get(LOCALE_COOKIE)?.value
  if (cookieVal === 'es' || cookieVal === 'en') return cookieVal

  const accept = h.get('accept-language') ?? ''
  return accept.toLowerCase().startsWith('es') ? 'es' : 'en'
}

El layout raíz lo usa para <html lang>, así que una URL en español siempre produce lang="es", aunque tu cookie diga inglés. Esto importa: una página con contenido en español cuyo atributo lang dice inglés envía señales contradictorias.

Y las páginas declaran su par con hreflang mediante la Metadata API:

alternates: {
  canonical: URL,
  languages: {
    en: PATH_EN,
    es: PATH_ES,
    'x-default': PATH_EN,
  },
},

Verificarlo Como Bot

Pedí las seis URLs en español con el user agent de Bingbot y sin Accept-Language:

curl -s -o /dev/null -w '%{http_code}\n' \
  -A 'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)' \
  https://www.fredhii.com/publicaciones

Las seis respondieron 200 con <html lang="es">. Antes del cambio, la misma petición devolvía un 307 hacia la página en inglés.


Nota: Páginas 404 Que Responden 200

Mientras probaba, también pedí una publicación que no existe y recibí HTTP 200 en lugar de 404. La página mostraba la interfaz de "no encontrado", pero el código de estado era incorrecto.

La causa es el boundary de loading.tsx. Con un loading boundary, la respuesta empieza a transmitirse (streaming) antes de que la página decida que no existe, y una vez enviada la línea de estado ya no se puede cambiar. Next.js lo compensa inyectando:

<meta name="robots" content="noindex" />

Así los buscadores no indexan la página, que es lo que importa para el SEO. También probé dynamicParams = false; en rutas que se renderizan de forma dinámica no cambió el código de estado. Decidí que el comportamiento con noindex es aceptable en lugar de quitar el estado de carga.


Puntos Clave

  • No redirijas por idioma: los crawlers normalmente no envían Accept-Language ni cookies, así que siempre terminan en el idioma por defecto
  • Haz que la URL sea la fuente de verdad del idioma y usa hreflang para conectar los pares
  • En Next.js 16, middleware.ts ahora es proxy.ts; puedes pasarle datos a la aplicación modificando los encabezados de la petición en NextResponse.next
  • Revisa tus páginas con un user agent de bot, no solo con tu navegador
  • Bajo un boundary de loading.tsx, las páginas no encontradas pueden responder 200 con una meta etiqueta noindex

Suscribite al newsletter

Te aviso cuando publico algo nuevo.

Cuidamos tus datos. Leé nuestra política de privacidad.