Managing Translations
Once your site serves multiple locales, you need a reliable way to store the actual translated text and pull the right string for the active language at render time. Astro has no built-in translation runtime, which is a feature, not a gap: because pages render on the server (or at build time), you can resolve every string into static HTML and ship zero JavaScript for translations. The pattern is simple — keep dictionaries in plain TypeScript objects and write a small lookup helper that components import.
Structuring translation dictionaries
The most maintainable approach is one object per locale, keyed by a stable string identifier. Keep these in a dedicated module so both .astro components and utility functions can import them.
// src/i18n/ui.ts
export const languages = {
en: "English",
fr: "Français",
} as const;
export const defaultLang = "en";
export const ui = {
en: {
"nav.home": "Home",
"nav.about": "About",
"nav.blog": "Blog",
"hero.cta": "Get started",
},
fr: {
"nav.home": "Accueil",
"nav.about": "À propos",
"nav.blog": "Blogue",
"hero.cta": "Commencer",
},
} as const;
Using flat, dotted keys (nav.home) instead of deeply nested objects keeps lookups trivial and lets TypeScript infer the exact set of valid keys, so a typo in a key name becomes a compile-time error.
Resolving the active locale
Astro exposes the current request URL on Astro.url, and when you use i18n routing the path begins with the locale segment (e.g. /fr/about). A tiny parser turns that path into a locale code.
// src/i18n/utils.ts
import { ui, defaultLang } from "./ui";
export function getLangFromUrl(url: URL) {
const [, lang] = url.pathname.split("/");
if (lang in ui) return lang as keyof typeof ui;
return defaultLang;
}
If you have configured
i18ninastro.config.mjs, you can also read the resolved locale directly fromAstro.currentLocaleinstead of parsing the path. The manual parser above is a dependency-free fallback that works in any setup.
Building the lookup (t) function
The convention from most i18n ecosystems is a t() function that takes a key and returns the translated string. Returning a typed closure gives you autocomplete on keys and a safe fallback to the default language when a translation is missing.
// src/i18n/utils.ts (continued)
export function useTranslations(lang: keyof typeof ui) {
return function t(key: keyof (typeof ui)[typeof defaultLang]) {
return ui[lang][key] ?? ui[defaultLang][key];
};
}
Because t falls back to defaultLang, a missing French string degrades gracefully to English rather than rendering an empty node.
Using translations in a component
Put it together inside the component script fence. The lookup happens during rendering, so the output HTML contains only the final translated text.
---
// src/components/Nav.astro
import { getLangFromUrl, useTranslations } from "../i18n/utils";
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
---
<nav>
<a href={`/${lang}/`}>{t("nav.home")}</a>
<a href={`/${lang}/about`}>{t("nav.about")}</a>
<a href={`/${lang}/blog`}>{t("nav.blog")}</a>
<button>{t("hero.cta")}</button>
</nav>
Output:
<nav>
<a href="/fr/">Accueil</a>
<a href="/fr/about">À propos</a>
<a href="/fr/blog">Blogue</a>
<button>Commencer</button>
</nav>
No client-side JavaScript is shipped — the strings are baked into the markup at render time.
Interpolation and pluralization
Plain dictionaries cover most marketing and UI copy, but variable interpolation needs a slightly richer helper. Store templates and replace named placeholders.
// src/i18n/utils.ts (continued)
export function interpolate(template: string, vars: Record<string, string | number>) {
return template.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ""));
}
// usage in a component script:
// const msg = interpolate(t("cart.items"), { count: 3 });
For real pluralization rules (which differ widely between languages), reach for the platform-native Intl.PluralRules rather than hand-rolling logic.
Choosing a storage strategy
| Strategy | Best for | Trade-off |
|---|---|---|
| Inline TS objects | UI labels, navigation, small sites | Lives in the bundle; recompiles on edit |
| JSON files per locale | Larger string sets, translator handoff | Needs an import or fs read |
| Content collections | Long-form translated pages/posts | Heavier; schema-validated, queryable |
| Headless CMS | Non-developer editing, frequent updates | Network dependency, build coupling |
Avoid loading translation files inside a
client:*island unless you truly need runtime switching. Doing so ships the dictionary to the browser and defeats Astro’s zero-JS default. Resolve strings on the server and pass only the final text as props.
Best Practices
- Keep one dictionary module per locale and use flat, dotted keys so TypeScript can validate them at build time.
- Always provide a default-language fallback in
t()so missing translations never render empty. - Resolve strings on the server and pass plain text to islands rather than shipping dictionaries to the client.
- Use
Intl.PluralRules,Intl.NumberFormat, andIntl.DateTimeFormatfor locale-aware numbers, dates, and plurals instead of custom logic. - Co-locate the
getLangFromUrl/useTranslationshelpers insrc/i18n/so every component imports the same source of truth. - Promote large or frequently edited string sets to JSON files or content collections to keep components lean.