i18n en una App Astro Multi-Framework: Resolviendo el Límite SSR/Cliente
El verdadero reto de la internacionalización en Astro no es el enrutamiento — es leer de forma segura el locale actual desde componentes React y Svelte que primero se renderizan del lado del servidor.
El i18n integrado de Astro maneja el enrutamiento de forma limpia: /en/projects, /es/proyectos, detección de locale, redirecciones. La parte difícil no es la capa de enrutamiento — es obtener esa información de locale dentro de componentes React y Svelte que se renderizan en dos contextos diferentes.
El Setup
El portafolio usa la configuración nativa de i18n de Astro con locales con prefijo:
// astro.config.mjs
i18n: {
defaultLocale: "en",
locales: ["en", "es"],
routing: { prefixDefaultLocale: true },
}
Los strings de traducción viven en src/i18n/ como archivos TypeScript por funcionalidad, unificados en un objeto ui. Consumirlos desde un archivo .astro es trivial — tienes acceso a Astro.url en tiempo de render:
// Componente Astro — server-side, siempre funciona
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
El problema aparece cuando esa misma lógica necesita ejecutarse dentro de un componente React o Svelte.
Por Qué window.location Rompe
La solución obvia — leer window.location.pathname directamente — lanza un error durante SSR:
ReferenceError: window is not defined
Astro renderiza cada componente a HTML en el servidor antes de enviarlo al navegador. En ese punto, window no existe. Si lo accedes a nivel de módulo o durante el primer pase de render, el build falla.
El Fix Incorrecto
Envolver window.location en un null check parece razonable:
const lang = typeof window !== 'undefined'
? getLangFromUrl(new URL(window.location.href))
: 'en';
Esto silencia el error pero introduce otro bug: el SSR renderiza siempre con 'en' como predeterminado independientemente de la ruta real, causando un flash de locale en el primer paint para usuarios en español — o peor, estado incorrecto de la UI al hidratar.
El Fix Correcto
La solución es específica por framework y usa el ciclo de vida de montaje de cada uno para diferir la lectura del locale hasta que el componente se ejecuta en el navegador.
React — useEffect corre después de la hidratación, nunca durante SSR:
// src/hooks/usei18n.ts
export const useI18n = (route?: TranslateArgs) => {
const [text, setText] = useState('');
const [lang, setLang] = useState<'en' | 'es'>('en');
useEffect(() => {
const language = getLangFromUrl(new URL(window.location.href));
setLang(language);
if (!route) return;
const t = useTranslations(language);
setText(t(route));
}, [route]);
return route ? [text, lang] : [lang];
};
Svelte — window.location se puede leer de forma síncrona porque el SSR de Svelte omite la ejecución de bloques reactivos que dependen de APIs del navegador:
// src/hooks/usei18nSvelte.ts
export function useI18n(route?: TranslateArgs) {
const lang = getLangFromUrl(new URL(window.location.href));
if (!route) return [lang];
const t = useTranslations(lang);
return [t(route), lang];
}
La diferencia clave: el hook de Svelte se llama desde código de inicialización de componente que Astro omite durante SSR. El hook de React requiere useEffect para garantizar ejecución solo en el navegador.
El Patrón General
Este problema es una instancia específica de una restricción general del SSR: cualquier API del navegador (window, document, navigator, localStorage) no está disponible durante el render del servidor. El modelo mental correcto es tratar el render del servidor y la hidratación del cliente como dos entornos de ejecución separados que comparten el mismo código.
Para i18n específicamente, la arquitectura más limpia es pasar el locale como prop desde el padre Astro (que tiene Astro.url) hacia el componente de framework. El enfoque basado en hooks funciona, pero introduce un ciclo de render antes de que aparezca el texto — aceptable para UI secundaria, no ideal para contenido above-the-fold.
Cuando el rendimiento importa, pasa lang como prop. Cuando importa la conveniencia (componentes profundamente anidados), usa el hook.