i18n in a Multi-Framework Astro App: Solving the SSR/Client Boundary
The real challenge of internationalization in Astro isn't the routing — it's safely reading the current locale from React and Svelte components that render server-side first.
Astro’s built-in i18n handles routing cleanly: /en/projects, /es/proyectos, locale detection, redirects. The hard part isn’t the routing layer — it’s getting that locale information into React and Svelte components that render in two different contexts.
The Setup
The portfolio uses Astro’s native i18n config with prefixed locales:
// astro.config.mjs
i18n: {
defaultLocale: "en",
locales: ["en", "es"],
routing: { prefixDefaultLocale: true },
}
Translation strings live in src/i18n/ as per-feature TypeScript files, merged into a single ui object. Consuming them from an .astro file is trivial — you have access to Astro.url at render time:
// Astro component — server-side, always works
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
The problem surfaces when that same logic needs to run inside a React or Svelte component.
Why window.location Breaks
The naive solution — just read window.location.pathname — throws during SSR:
ReferenceError: window is not defined
Astro renders every component to HTML on the server before shipping to the browser. At that point, window doesn’t exist. If you access it at the module level or during the initial render pass, the build fails.
The Wrong Fix
Wrapping window.location in a null check seems reasonable:
const lang = typeof window !== 'undefined'
? getLangFromUrl(new URL(window.location.href))
: 'en';
This silences the error but introduces a different bug: SSR renders with 'en' as the default regardless of the actual route, causing a locale flash on first paint for Spanish users — or worse, wrong UI state on hydration.
The Right Fix
The solution is framework-specific and uses each framework’s mount lifecycle to defer locale reading until the component is running in the browser.
React — useEffect runs after hydration, never during 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 can be read synchronously in reactive blocks because Svelte’s SSR renders components without executing reactive statements that depend on browser APIs:
// 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];
}
The key difference: Svelte’s hook is called from within component initialization code that Astro knows to skip during SSR. React’s hook requires useEffect to guarantee browser-only execution.
The Broader Pattern
This problem is a specific instance of a general SSR constraint: any browser API (window, document, navigator, localStorage) is unavailable during server rendering. The correct mental model is to treat the server render and the client hydration as two separate execution environments that share the same code.
For i18n specifically, the cleanest architecture passes locale as a prop from the Astro parent (which has Astro.url) down to the framework component. The hook-based approach works, but it introduces a render cycle before text appears — acceptable for secondary UI, not ideal for above-the-fold content.
When performance matters, pass lang as a prop. When convenience matters (deeply nested components), use the hook.