Cuando un sitio tiene dos idiomas, lo habitual es traducir los textos y dejar las URLs en un solo idioma: /es/services y /en/services. Para SEO es mejor que la URL también esté traducida: /servicios en español y /en/services en inglés. Next.js no lo hace solo, y no hace falta una librería para conseguirlo. Así lo resolvimos en este sitio.
La idea
Las carpetas de app/ tienen un nombre interno fijo, en inglés: app/[locale]/services, app/[locale]/contact. Lo que ve el visitante es otra cosa. Un archivo proxy.ts traduce la URL pública a la carpeta interna antes de que Next.js renderice nada.
- El español es la raíz, sin prefijo:
/servicios,/contacto. - El inglés va bajo
/en:/en/services,/en/contact. - La carpeta es la misma en ambos casos.
Desde Next.js 16 este archivo se llama proxy.ts (antes middleware.ts; la convención antigua está marcada como obsoleta en la documentación).
Paso 1: un único mapa de rutas
Toda la información vive en un solo archivo, i18n/routes.ts:
export const SEGMENTS = {
services: { internal: 'services', es: 'servicios', en: 'services' },
contact: { internal: 'contact', es: 'contacto', en: 'contact' },
privacy: {
internal: 'privacy',
es: 'politica-de-privacidad',
en: 'privacy-policy',
},
// …
} as const;Cada entrada dice cómo se llama la carpeta interna y cómo se escribe en cada idioma. Ningún componente escribe una URL a mano: todos usan funciones como segmentPath('en', 'contact'), que devuelve /en/contact. Si un día cambias contacto por escribenos, lo cambias en un sitio.
Paso 2: construir las URLs con una función
const prefix = (locale: Locale) =>
locale === DEFAULT_LOCALE ? '' : `/${locale}`;
export const segmentPath = (locale: Locale, key: SegmentKey) =>
`${prefix(locale)}/${SEGMENTS[key][locale]}`;El idioma por defecto no lleva prefijo; los demás sí.
Paso 3: el proxy traduce
proxy.ts mira la URL, detecta el idioma y reescribe hacia la carpeta interna:
export function proxy(req: NextRequest) {
const { pathname, search } = req.nextUrl;
const { locale, explicit, rest } = splitLocale(pathname);
const prefix = locale === DEFAULT_LOCALE ? '' : `/${locale}`;
// /es/... se redirige a la URL sin prefijo: una sola URL por página
if (explicit && locale === DEFAULT_LOCALE) {
return NextResponse.redirect(new URL(join(...rest) + search, req.url), 308);
}
let internal = rest;
const match = rest[0] ? matchSegment(locale, rest[0]) : null;
if (match) {
// pidieron /services en español: redirige a la forma canónica /servicios
if (match.canonical) {
return NextResponse.redirect(
new URL(prefix + join(match.canonical, ...rest.slice(1)) + search, req.url),
308,
);
}
internal = [match.internal, ...rest.slice(1)];
}
return NextResponse.rewrite(new URL(join(locale, ...internal) + search, req.url));
}Hace tres cosas:
- Reescribe
/serviciosa/es/services. El visitante sigue viendo/servicios. - Redirige (308) a la forma correcta cuando alguien entra con el segmento de otro idioma, por ejemplo
/servicesen español. Así cada contenido tiene una sola URL. - Quita el prefijo
/esdel idioma por defecto con otra redirección.
Y el matcher deja fuera lo que no debe pasar por aquí:
export const config = {
matcher: ['/((?!api/|admin(?:/|$)|_next/).*)'],
};Paso 4: dile a Google que son la misma página
Con URLs distintas por idioma, necesitas hreflang. Cada página debe saber cuál es su equivalente en el otro idioma. Nosotros pasamos las URLs alternas a un único componente de plantilla, que genera el selector ES | EN y las etiquetas hreflang. Un artículo sin traducción simplemente no se anuncia en el otro idioma.
Lo que gana y lo que cuesta
Gana: URLs legibles en cada idioma, ninguna dependencia, y todo el comportamiento en un archivo que puedes leer de principio a fin.
Cuesta: tienes que mantener tú el mapa y las redirecciones. Si el sitio crece a muchos idiomas, o necesitas formatear fechas, plurales o monedas por idioma, una librería de i18n sigue siendo una buena idea. Para dos idiomas y un sitio de contenido, el mapa se queda corto antes de ser un problema.
Dos advertencias
- Prueba las redirecciones con
curl -I. Un bucle de redirecciones entre/es/xy/xse ve rápido, pero solo si lo pruebas. - No pongas en el
matcherrutas que no necesitan traducción, como/apio el panel de administración. En nuestro caso, el admin de Payload queda fuera.
Si tu sitio es en Next.js y quieres ver cómo cambió el framework, lee qué cambia en Next.js 16 para sitios corporativos.
Escrito por
Derian Pinto
Founder & Full Stack Developer
Fundador de Codegea. Desarrolla sitios, tiendas y plataformas a medida, e integra y da soporte a Arc XP para medios.