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:
| Abordagem | PT | EN |
|---|---|---|
| Subdomínio | pt.site.com | en.site.com |
| Path prefix | site.com/pt/ | site.com/en/ |
| Raiz + prefix | site.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.