
Fredy Acuna / October 30, 2026 / 7 min read
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-distributionLas 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.
Accept-Language esconden páginas a los crawlersmiddleware.ts por la convención proxy.ts de Next.js 16La 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.
Crawlers como Googlebot y Bingbot normalmente no envían el encabezado Accept-Language y no conservan cookies entre peticiones. Así que para ellos:
Accept-Language, así que detectAcceptLanguage devuelve 'en'/publicaciones no es la ruta en inglés, así que redirige (307) a /postsCada 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.
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.
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./) 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).
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,
},
},
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.
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.
Accept-Language ni cookies, así que siempre terminan en el idioma por defectomiddleware.ts ahora es proxy.ts; puedes pasarle datos a la aplicación modificando los encabezados de la petición en NextResponse.nextloading.tsx, las páginas no encontradas pueden responder 200 con una meta etiqueta noindex