i18n with Astro: language switcher, shared components, and bilingual slugs
When I decided my portfolio needed to support both PT-BR and English, the first temptation was to duplicate every page. Sounds simple — until you have 6 pages, 2 languages, and realize every layout change needs to be applied to 12 files. I did that. Then undid it. And rebuilt it properly.
This article documents the i18n system I use in this portfolio, built with Astro.
URL structure
The first decision is where the language lives in the URL. The most common options:
| Approach | PT | EN |
|---|---|---|
| Subdomain | pt.site.com | en.site.com |
| Path prefix | site.com/pt/ | site.com/en/ |
| Root + prefix | site.com/ | site.com/en/ |
I went with root + prefix: PT lives at the root (/, /sobre, /blog), EN lives under /en/ (/en/, /en/about, /en/blog). This is the cleanest approach when one language is primary — PT-BR in my case.
Detecting language from the URL
With this structure, language detection is trivial:
// src/i18n/utils.ts
export function getLangFromUrl(url: URL): Lang {
const [, first] = url.pathname.split('/');
if (first === 'en') return 'en';
return defaultLang; // 'pt'
}
Any Astro component can call getLangFromUrl(Astro.url) to know which language it’s rendering in. No global context, no prop drilling — just the URL.
The translation system
All UI strings live in a single configuration file:
// src/i18n/translations.ts
export const ui = {
pt: {
'nav.home': 'Início',
'nav.blog': 'Blog',
'blog.back': '← Todos os posts',
'blog.readtime': 'min de leitura',
'contact.title': 'Vamos conversar.',
// ...
},
en: {
'nav.home': 'Home',
'nav.blog': 'Blog',
'blog.back': '← All posts',
'blog.readtime': 'min read',
'contact.title': "Let's talk.",
// ...
},
} as const;
useTranslations returns a typed t() function — TypeScript will complain if you try to use a key that doesn’t exist:
export function useTranslations(lang: Lang) {
return function t(key: keyof (typeof ui)[typeof defaultLang]): string {
return (ui[lang] as Record<string, string>)[key] ?? ui[defaultLang][key];
};
}
Usage in any component:
---
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
---
<h1>{t('contact.title')}</h1>
Eliminating duplication with shared components
The most common mistake in Astro i18n projects is creating pages/sobre.astro and pages/en/about.astro with 98% identical code. Any layout fix becomes double work.
The fix is moving all the logic to a shared component and leaving the pages as 3-line wrappers:
<!-- src/components/pages/AboutPage.astro -->
---
interface Props { lang: 'pt' | 'en' }
const { lang } = Astro.props;
const t = useTranslations(lang);
const content = aboutContent[lang]; // bilingual data file
---
<!-- Full HTML + CSS here, written once -->
<!-- src/pages/sobre.astro -->
---
import AboutPage from '../components/pages/AboutPage.astro';
---
<AboutPage lang="pt" />
<!-- src/pages/en/about.astro -->
---
import AboutPage from '../../components/pages/AboutPage.astro';
---
<AboutPage lang="en" />
Longer content (bio, job history, tech stack) goes in TypeScript data files:
// src/data/about.ts
export const aboutContent = {
pt: {
bio: 'Desenvolvedor backend...',
jobs: [{ company: 'LucraHub', role: 'Fundador & Tech Lead', ... }],
},
en: {
bio: 'Backend developer...',
jobs: [{ company: 'LucraHub', role: 'Founder & Tech Lead', ... }],
},
};
The route map
For the language switcher to work on regular pages, I need to know that /sobre ↔ /en/about, /contato ↔ /en/contact, etc. This mapping is explicit:
// src/i18n/translations.ts
export const routeMap: Record<string, string> = {
'/': '/en/',
'/sobre': '/en/about',
'/depoimentos': '/en/testimonials',
'/contato': '/en/contact',
'/arquivo': '/en/archive',
// and the reverse
'/en/': '/',
'/en/about': '/sobre',
// ...
};
getAlternateUrl resolves the alternate URL for any pathname:
export function getAlternateUrl(pathname: string): string {
const normalized = pathname === '/' ? '/' : pathname.replace(/\/$/, '');
if (routeMap[normalized]) return routeMap[normalized];
// Blog posts: /blog/slug ↔ /en/blog/slug (base case)
if (normalized.startsWith('/en/blog/')) return normalized.replace('/en/blog/', '/blog/');
if (normalized.startsWith('/blog/')) return '/en' + normalized;
return normalized.startsWith('/en') ? '/' : '/en/';
}
The LangSwitcher component
The switcher receives currentLang and alternateUrl as props — the logic of which URL to use is already resolved before it gets here:
<!-- src/components/LangSwitcher.astro -->
---
interface Props {
currentLang: Lang;
alternateUrl: string;
}
const { currentLang, alternateUrl } = Astro.props;
---
<div class="lang-switcher" data-alternate-url={alternateUrl}>
<!-- dropdown with PT 🇧🇷 and EN 🇺🇸 -->
</div>
<script>
const switcher = document.getElementById('lang-switcher');
const alternateUrl = switcher.dataset.alternateUrl;
// When the user selects the alternate language:
window.location.href = alternateUrl;
localStorage.setItem('preferred-lang', selectedLang);
</script>
The JavaScript also detects the browser language on the first visit and auto-redirects:
const saved = localStorage.getItem('preferred-lang');
if (!saved) {
const browserLang = navigator.language.toLowerCase();
const preferred = browserLang.startsWith('en') ? 'en' : 'pt';
localStorage.setItem('preferred-lang', preferred);
if (preferred !== currentLang) {
window.location.replace(alternateUrl);
}
}
On the first visit from a user with an English browser, they land on /en/ automatically. The preference is saved to localStorage for future visits.
The bilingual blog post problem
Blog posts have a specific challenge: slugs can differ between languages. My RLS article, for example:
- PT:
/blog/fastapi-rls-multitenant - EN:
/en/blog/fastapi-rls-multitenant-en
A naive getAlternateUrl would map /blog/fastapi-rls-multitenant → /en/blog/fastapi-rls-multitenant, which doesn’t exist.
The fix was adding a translationSlug field to the article frontmatter:
<!-- fastapi-rls-multitenant.md -->
---
lang: 'pt'
translationSlug: 'fastapi-rls-multitenant-en'
---
<!-- fastapi-rls-multitenant-en.md -->
---
lang: 'en'
translationSlug: 'fastapi-rls-multitenant'
---
The BlogPost.astro layout uses this field to compute the correct URL before passing it to the Header:
---
const alternateUrl = translationSlug
? lang === 'en'
? `/blog/${translationSlug}`
: `/en/blog/${translationSlug}`
: getAlternateUrl(Astro.url.pathname);
---
<Header alternateUrl={alternateUrl} />
And the slug pages filter by language to prevent wrong articles from being served:
// pages/blog/[...slug].astro — PT posts only
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts
.filter((post) => post.data.lang !== 'en')
.map((post) => ({ params: { slug: post.id }, props: post }));
}
// pages/en/blog/[...slug].astro — EN posts only
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts
.filter((post) => post.data.lang === 'en')
.map((post) => ({ params: { slug: post.id }, props: post }));
}
The result
The entire system is under 200 lines of TypeScript/Astro and covers:
- URL-based language detection (zero server-side JavaScript)
- Typed translations with
as const(key errors caught at compile time) - Shared components — zero layout or CSS duplication
- Switcher with auto-detection and localStorage persistence
- Bilingual blog posts with independent slugs
What I like most about this architecture: every piece has a single clear responsibility. The URL tells you the language. The routeMap tells you where to go. The translationSlug handles edge cases. No magic — just composition.