Skip to content
Astro as i18n 4 min read

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 i18n in astro.config.mjs, you can also read the resolved locale directly from Astro.currentLocale instead 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

StrategyBest forTrade-off
Inline TS objectsUI labels, navigation, small sitesLives in the bundle; recompiles on edit
JSON files per localeLarger string sets, translator handoffNeeds an import or fs read
Content collectionsLong-form translated pages/postsHeavier; schema-validated, queryable
Headless CMSNon-developer editing, frequent updatesNetwork 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, and Intl.DateTimeFormat for locale-aware numbers, dates, and plurals instead of custom logic.
  • Co-locate the getLangFromUrl / useTranslations helpers in src/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.
Last updated June 14, 2026
Was this helpful?