ADR-0031 Internacionalização
Padrão da empresa para i18n das apps — Paraglide JS, compilado para funções de mensagem tipadas, com o locale no URL e resolvido por pedido no servidor.
- Decisão
- Paraglide JSConstruir a app
- Estado
- Aceite
- Data
- Âmbito
- Predefinição da empresa para todas as novas apps
Ligações
Contexto
Seção intitulada “Contexto”As apps têm de poder sair em mais do que uma língua; português (pt-PT) e inglês são o par habitual.
- Renderizamos no servidor e fazemos streaming (ADR-0002): o locale tem de ser conhecido por pedido, e o servidor e o browser têm de concordar, senão a hidratação falha
- Um processo serve muitos utilizadores (ADR-0012): um locale guardado numa variável ao nível do módulo passa de um pedido para outro
- TypeScript em todo o lado: uma mensagem ou um parâmetro em falta deve fazer falhar o build, não mostrar uma chave a um utilizador
- Também se produz texto fora do React: os emails (ADR-0029) e os jobs (ADR-0028) não têm um pedido de onde ler o locale
O que está em aberto: a biblioteca de i18n, de onde vem o locale e como se escrevem as mensagens.
Opções consideradas
Seção intitulada “Opções consideradas”1. Paraglide JS
Seção intitulada “1. Paraglide JS”Um compilador: os ficheiros de mensagens passam a funções tipadas e tree-shakable.
Prós
m.greeting({ name })é uma chamada de função — uma mensagem ou um parâmetro em falta é um erro de tipos, e as mensagens não usadas não são enviadas- Um plugin Vite e um exemplo para TanStack Start no próprio repositório: middleware de servidor mais o
rewritedo TanStack Router - O middleware de servidor guarda o locale do pedido em
AsyncLocalStorage, pelo que pedidos concorrentes não se misturam - Uma mensagem pode ser forçada para um locale em cada chamada — o que os emails e os jobs precisam
Contras
- A versão 3.0 saiu em 2026-11-03; a maioria dos exemplos online ainda mostra a v2
- Formato de mensagens próprio, não ICU MessageFormat
- Um ecossistema mais pequeno do que o do i18next
2. i18next com react-i18next
Seção intitulada “2. i18next com react-i18next”Prós: o maior ecossistema; as ferramentas e os tradutores conhecem-no. Contras: as mensagens são procuradas por chave em string em runtime; as chaves tipadas exigem configuração extra; uma instância por pedido no servidor fica por nossa conta; namespaces inteiros vão para o browser se não os dividirmos.
3. FormatJS (react-intl)
Seção intitulada “3. FormatJS (react-intl)”Prós: ICU MessageFormat, um padrão que os tradutores conhecem; maduro. Contras: IDs em string; um parser em runtime ou um passo extra de compilação; mais boilerplate por mensagem.
Decisão
Seção intitulada “Decisão”Paraglide JS é o padrão da empresa para a internacionalização das apps.
Em concreto:
- Pacote:
@inlang/paraglide-js3.x, fixado numa versão exata (3.2.2 à data de escrita) enquanto a major for tão recente; atualizar deliberadamente - Plugin Vite:
paraglideVitePlugin({ project: "./project.inlang", outdir: "./src/paraglide", strategy: ["url", "cookie", "preferredLanguage", "baseLocale"] }). Osrc/paraglide/é output gerado: nunca se edita, e não é uma das pastas de camadas do ADR-0026 - Os locales são por produto, declarados em
project.inlang/settings.json. As mensagens vivem emmessages/{locale}.json, um ficheiro por locale - O URL transporta o locale. Os ficheiros de rotas mantêm-se canónicos (
/about); orewritedo TanStack Router mapeia os URLs públicos comdeLocalizeUrlelocalizeUrl - Servidor: o
src/server.tsenvolve o handler do Start comparaglideMiddleware(request, () => handler(request)). Passar o pedido original — o pedido reescrito pelo middleware causa um ciclo de redirecionamentos quando o router também reescreve - Documento: a rota raiz renderiza
<html lang={getLocale()} dir={getTextDirection()}> - Mudar de língua usa o
setLocale()com a navegação de página inteira predefinida, para que o URL, a renderização no servidor e o runtime fiquem alinhados - No código: importar o
mdosrc/paraglide/messages.jsgerado e chamarm.some_message({ … }). Sem chaves em string, sem concatenação de strings; os plurais e outras variantes são entradasmatchno ficheiro de mensagens - Fora de um pedido (emails, jobs), passar o locale explicitamente:
m.some_message({ … }, { locale }) - Datas, números e moeda formatam-se com
Intlusando ogetLocale()— não é trabalho do Paraglide - Não usamos:
experimentalPerLocaleBuildnem outras opções experimentais
Os fatores decisivos: mensagens com verificação de tipos e tree-shaking, um locale com âmbito de pedido no servidor, uma configuração documentada para TanStack Start e locales por chamada para emails e jobs. Aceitamos uma major com poucos dias e um ecossistema mais pequeno.
Consequências
Seção intitulada “Consequências”Mais fácil:
- Uma chave de tradução ou um parâmetro em falta faz falhar o
bun run check, não a produção - Só as mensagens que uma página usa chegam ao browser
- Os emails e os jobs renderizam na língua do destinatário com os mesmos ficheiros de mensagens
Mais difícil:
- A versão 3 é nova: contar com correções e com exemplos que já não se aplicam
- As regras de URL exigem cuidado: o
/api/*(ADR-0036) e os endpoints de health não podem ser redirecionados para um prefixo de locale - Os tradutores trabalham em ficheiros JSON ou nas ferramentas do inlang, não numa plataforma baseada em ICU
Do que abdicamos: do ICU MessageFormat e do ecossistema do i18next. Aceite — a segurança em tempo de compilação e os bundles mais pequenos importam-nos mais do que a amplitude das ferramentas.

