i18n with Astro: language switcher, shared components, and bilingual slugs

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:

ApproachPTEN
Subdomainpt.site.comen.site.com
Path prefixsite.com/pt/site.com/en/
Root + prefixsite.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.