i18n com Astro: seletor de idiomas, componentes compartilhados e slugs bilíngues

i18n com Astro: seletor de idiomas, componentes compartilhados e slugs bilíngues

Quando decidi que meu portfólio precisava suportar PT-BR e inglês, a primeira tentação foi duplicar cada página. Parece simples — até você ter 6 páginas, 2 idiomas e perceber que toda mudança de layout precisa ser feita em 12 arquivos. Fiz isso. Desfiz. E construí do jeito certo.

Este artigo documenta o sistema de i18n que uso neste portfólio, construído com Astro.

A estrutura de URLs

A primeira decisão é onde o idioma mora na URL. As opções mais comuns:

AbordagemPTEN
Subdomíniopt.site.comen.site.com
Path prefixsite.com/pt/site.com/en/
Raiz + prefixsite.com/site.com/en/

Escolhi raiz + prefix: PT fica na raiz (/, /sobre, /blog), EN fica sob /en/ (/en/, /en/about, /en/blog). É a abordagem mais limpa para sites onde um idioma é principal — PT-BR no meu caso.

Detectando o idioma pela URL

Com essa estrutura, detectar o idioma é trivial:

// src/i18n/utils.ts
export function getLangFromUrl(url: URL): Lang {
  const [, first] = url.pathname.split('/');
  if (first === 'en') return 'en';
  return defaultLang; // 'pt'
}

Qualquer componente Astro pode chamar getLangFromUrl(Astro.url) e saber em qual idioma está renderizando. Sem contexto global, sem prop drilling — só a URL.

O sistema de traduções

Todas as strings de UI ficam em um único arquivo de configuração:

// 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;

A função useTranslations retorna um t() tipado — o TypeScript reclama se você tentar usar uma chave que não existe:

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];
  };
}

Uso em qualquer componente:

---
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
---
<h1>{t('contact.title')}</h1>

Eliminando duplicação com componentes compartilhados

O erro mais comum em projetos i18n com Astro é criar pages/sobre.astro e pages/en/about.astro com 98% do código idêntico. Qualquer correção de layout vira trabalho duplo.

A solução é mover toda a lógica para um componente compartilhado e deixar as páginas como wrappers de 3 linhas:

<!-- src/components/pages/AboutPage.astro -->
---
interface Props { lang: 'pt' | 'en' }
const { lang } = Astro.props;
const t = useTranslations(lang);
const content = aboutContent[lang]; // arquivo de dados bilíngue
---
<!-- HTML + CSS completo aqui, uma única vez -->
<!-- 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" />

Conteúdo longo (bio, experiência, stack) vai em arquivos de dados TypeScript:

// 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', ... }],
  },
};

O mapa de rotas

Para o switcher de idioma funcionar em páginas normais, preciso saber que /sobre/en/about, /contato/en/contact, etc. Esse mapeamento fica explícito:

// src/i18n/translations.ts
export const routeMap: Record<string, string> = {
  '/': '/en/',
  '/sobre': '/en/about',
  '/depoimentos': '/en/testimonials',
  '/contato': '/en/contact',
  '/arquivo': '/en/archive',
  // e o inverso
  '/en/': '/',
  '/en/about': '/sobre',
  // ...
};

A função getAlternateUrl resolve o URL alternativo para qualquer 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 (caso base)
  if (normalized.startsWith('/en/blog/')) return normalized.replace('/en/blog/', '/blog/');
  if (normalized.startsWith('/blog/')) return '/en' + normalized;

  return normalized.startsWith('/en') ? '/' : '/en/';
}

O componente LangSwitcher

O switcher recebe currentLang e alternateUrl como props — toda a lógica de qual URL usar já foi resolvida antes:

<!-- src/components/LangSwitcher.astro -->
---
interface Props {
  currentLang: Lang;
  alternateUrl: string;
}
const { currentLang, alternateUrl } = Astro.props;
---

<div class="lang-switcher" data-alternate-url={alternateUrl}>
  <!-- dropdown com PT 🇧🇷 e EN 🇺🇸 -->
</div>

<script>
  const switcher = document.getElementById('lang-switcher');
  const alternateUrl = switcher.dataset.alternateUrl;

  // Ao selecionar o idioma alternativo:
  window.location.href = alternateUrl;
  localStorage.setItem('preferred-lang', selectedLang);
</script>

O JavaScript também detecta o idioma do browser na primeira visita e redireciona automaticamente:

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);
  }
}

Na primeira visita de um usuário com browser em inglês, ele vai para /en/ automaticamente. A preferência fica salva no localStorage para visitas futuras.

O problema dos artigos bilíngues

Artigos de blog têm um problema específico: os slugs podem ser diferentes entre idiomas. Meu artigo sobre RLS, por exemplo:

  • PT: /blog/fastapi-rls-multitenant
  • EN: /en/blog/fastapi-rls-multitenant-en

O getAlternateUrl simples não resolve isso — ele faria /blog/fastapi-rls-multitenant/en/blog/fastapi-rls-multitenant, que não existe.

A solução foi adicionar um campo translationSlug no frontmatter dos artigos:

<!-- fastapi-rls-multitenant.md -->
---
lang: 'pt'
translationSlug: 'fastapi-rls-multitenant-en'
---
<!-- fastapi-rls-multitenant-en.md -->
---
lang: 'en'
translationSlug: 'fastapi-rls-multitenant'
---

O layout BlogPost.astro usa esse campo para calcular o URL correto antes de passar para o Header:

---
const alternateUrl = translationSlug
  ? lang === 'en'
    ? `/blog/${translationSlug}`
    : `/en/blog/${translationSlug}`
  : getAlternateUrl(Astro.url.pathname);
---
<Header alternateUrl={alternateUrl} />

E os slug pages filtram por idioma para não servir artigos errados:

// pages/blog/[...slug].astro — só posts PT
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 — só posts EN
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 }));
}

Resultado

O sistema inteiro tem menos de 200 linhas de TypeScript/Astro e cobre:

  • Detecção de idioma por URL (zero JavaScript no servidor)
  • Traduções tipadas com as const (erros de chave em tempo de compilação)
  • Componentes compartilhados — zero duplicação de layout ou CSS
  • Switcher com detecção automática e persistência via localStorage
  • Artigos bilíngues com slugs independentes

O que mais gosto nessa arquitetura: cada peça tem uma responsabilidade clara. A URL diz o idioma. O routeMap diz para onde ir. O translationSlug resolve casos especiais. Nenhuma mágica — só composição.