Part 16 of 16
A bilingual site with i18n
es/en routing with Astro, a typed dictionary, localized content, hreflang and parity guards so the languages never get out of sync.
The site was Spanish-only and I wanted to open it to English. Astro has i18n built into the routing, and for the texts I built my own dictionary with strong typing. The real challenge wasn’t translating: it was keeping both languages from getting out of sync with future changes.
The routing
In astro.config.mjs I declared the languages, with Spanish as the default and English prefixed:
i18n: {
defaultLocale: 'es',
locales: ['es', 'en'],
fallback: { en: 'es' },
routing: {
prefixDefaultLocale: false, // es served at /, en at /en/
fallbackType: 'rewrite',
},
},
With fallbackType: 'rewrite', Astro automatically generates the /en/ version of every page. Since the whole UI reads the language with Astro.currentLocale, the same page is served translated without duplicating files.
When a page isn’t translated yet, the fallback makes
/en/...serve the Spanish one. We don’t need it because we translated everything, but it’s a useful safety net.
The typed dictionary
I created src/i18n/dictionaries.ts: es is the source of truth and en is typed against its keys:
const es = {
'nav.home': 'Inicio',
'nav.projects': 'Proyectos',
// ...
} as const;
export type TranslationKey = keyof typeof es;
const en: Record<TranslationKey, string> = {
'nav.home': 'Home',
'nav.projects': 'Projects',
// if a key is missing → type error in the check
};
And a t() helper with interpolation and fallback:
export function t(locale, key, params?) {
const template = dictionaries[locale]?.[key] ?? dictionaries.es[key] ?? key;
if (!params) return template;
return Object.entries(params).reduce(
(acc, [k, v]) => acc.replaceAll(`{${k}}`, String(v)),
template
);
}
In any component it’s used like this:
---
const locale = (Astro.currentLocale ?? 'es') as Locale;
---
<h1>{t(locale, 'about.title')}</h1>
The <html> and the client-side language
The layout sets lang and a data-locale attribute that the client scripts read each time:
<html lang={getLangTag(locale)} data-locale={locale}>
The scripts that live in the pages persist between navigations, so the language is read fresh on each use (
document.documentElement.getAttribute('data-locale')), never captured at module load. It’s the same principle the stale container bug taught us.
Localized content
The tutorials live in src/content/tutoriales/es/ and en/, with the same file names:
export function normalizeId(id: string): string {
return id.startsWith('es/') || id.startsWith('en/') ? id.slice(3) : id;
}
export async function getPublishedTutorials(locale: Locale) {
const entries = await getCollection('tutoriales', ({ data }) => !data.draft);
return entries
.filter((entry) => (entry.id.startsWith('en/') ? 'en' : 'es') === locale)
.sort((a, b) => a.data.order - b.data.order);
}
In getStaticPaths the URLs are generated from the Spanish articles (normalizeId strips the prefix so the URL doesn’t change), and inside the page the article for the current language is resolved.
SEO: hreflang and sitemap
- The
<head>includes thelink rel="alternate"tags withhreflangfor es/en/x-default, computed withgetAbsoluteLocaleUrl. - The sitemap is configured with its own i18n block:
sitemap({
i18n: { defaultLocale: 'es', locales: { es: 'es-ES', en: 'en-US' } },
})
The dynamic routes generated by the fallback (
/en/tutoriales/*) didn’t make it into the sitemap. I added them withcustomPages, generated dynamically by reading thees/folder in the config itself.
The parity guards
The typed dictionary already fails astro check if a key is missing in English. For the content I added scripts/check-i18n.mjs, run as part of npm run check:
"check": "astro check && node scripts/check-i18n.mjs"
The script compares es/ and en/: missing or extra articles, mismatched order/part, empty title/description. If there’s any problem, it prints each error with the file path and exits with code 1 (the CI blocks the deployment). On GitHub Actions, it also writes a summary to the run’s Summary tab.
The profile data
The role, bio and objective come from environment variables. I added English versions with a fallback:
export function profileText(locale, field) {
const base = env[`SITE_${field}`];
const english = env[`SITE_${field}_EN`];
return locale === 'en' ? (english ?? base ?? '') : (base ?? '');
}
So if an English secret is missing in the CI, the site doesn’t show gaps: it falls back to the Spanish value.
The language switcher
In the navigation, a link that takes you to the same page in the other language:
import { getRelativeLocaleUrl } from 'astro:i18n';
const otherLocale = locale === 'es' ? 'en' : 'es';
const switchHref = getRelativeLocaleUrl(otherLocale, relativePath);
getRelativeLocaleUrl handles the prefix: from / it goes to /en/, and from /en/proyectos/ back to /proyectos/.
With this, the site became bilingual, indexable in both languages and proof against desync. And remember: any future text change is done in es and en at the same time; if not, the check will let you know.