Plano de Implementação: i18n com Paraglide no Frontend React
Contexto Atual
O monorepo frontend/ contém dois apps React que precisam de i18n:
| App | Stack | SSR? | Roteador |
|---|---|---|---|
apps/web | TanStack Start | ✅ SSR (Cloudflare Workers) | @tanstack/react-router v1 |
apps/admin | TanStack Router | ❌ Client-only SPA | @tanstack/react-router v1 |
Já existe uma implementação de Paraglide de referência no projeto: o app checkout/ (SvelteKit) usa @inlang/paraglide-js com:
checkout/project.inlang/settings.json: sourcept-BR, targeten- Mensagens em
checkout/messages/{languageTag}.json - Estratégia:
["cookie", "baseLocale"] - SSR via
paraglideMiddlewareemhooks.server.ts
1. Decisões de Arquitetura
1.1 Configuração Compartilhada vs. Separada
Decisão: Configuração compartilhada no monorepo.
frontend/
project.inlang/ ← settings.json compartilhado
messages/ ← mensagens compartilhadas
pt-BR.json
en.json
apps/web/
src/paraglide/ ← código gerado para o web app
apps/admin/
src/paraglide/ ← código gerado para o admin appMotivação:
- Mensagens compartilhadas = menos duplicação entre web e admin
- Cada app tem seu próprio
outdirpara o código gerado (não conflita) - Segue o mesmo padrão do checkout, mas com granularidade de app
1.2 Idiomas
| Tag | Idioma | Status |
|---|---|---|
pt-BR | Português (Brasil) | Source (idioma atual do app) |
en | Inglês | Primeiro target |
Futuro: es (Espanhol) pode ser adicionado depois.
1.3 Estratégia de Detecção de Locale
Web app (SSR): ["url", "cookie", "preferredLanguage", "baseLocale"]
- ✅ URL:
/en/feed,/pt-BR/feed— SEO-friendly, compartilhável - ✅ Cookie:
PARAGLIDE_LOCALE— persiste escolha entre visitas - ✅
preferredLanguage: detectaAccept-Languagedo navegador na primeira visita - ✅
baseLocale:pt-BRcomo fallback final
Admin app (client-only): ["url", "cookie", "baseLocale"]
- ✅ URL:
/en/members,/pt-BR/members - ✅ Cookie: persiste escolha
- ✅
baseLocale:pt-BRcomo fallback
2. Etapas de Implementação
Etapa 1: Configuração do Projeto Paraglide
1.1 Inicializar Paraglide no monorepo
cd frontend
npx @inlang/paraglide-js@latest initIsso cria frontend/project.inlang/settings.json:
{
"$schema": "https://inlang.com/schema/project-settings",
"sourceLanguageTag": "pt-BR",
"languageTags": ["pt-BR", "en"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "../messages/{languageTag}.json"
}
}1.2 Criar arquivos de mensagem
frontend/messages/pt-BR.json — todas as strings atuais do app em português (source) frontend/messages/en.json — traduções em inglês
Etapa 2: Configurar Vite Plugin em Ambos os Apps
2.1 Web app (frontend/apps/web/vite.config.ts)
Adicionar paraglideVitePlugin:
import { paraglideVitePlugin } from "@inlang/paraglide-js";
paraglideVitePlugin({
project: "./project.inlang",
outdir: "./src/paraglide",
outputStructure: "message-modules",
cookieName: "PARAGLIDE_LOCALE",
strategy: ["url", "cookie", "preferredLanguage", "baseLocale"],
urlPatterns: [
{
pattern: "/:path(.*)?",
localized: [
["pt-BR", "/:path(.*)?"],
["en", "/en/:path(.*)?"],
],
},
],
}),Ordem dos plugins importa: paraglideVitePlugin deve vir antes de tanstackStart e react.
2.2 Admin app (frontend/apps/admin/vite.config.ts)
Mesmo plugin, mas sem SSR:
paraglideVitePlugin({
project: "./project.inlang",
outdir: "./src/paraglide",
cookieName: "PARAGLIDE_LOCALE",
strategy: ["url", "cookie", "baseLocale"],
urlPatterns: [
{
pattern: "/:path(.*)?",
localized: [
["pt-BR", "/:path(.*)?"],
["en", "/en/:path(.*)?"],
],
},
],
}),Etapa 3: Integração com TanStack Router — URL Rewrite
3.1 Web app (frontend/apps/web/src/router.tsx)
Adicionar rewrite no router para normalizar URLs:
import { createRouter } from "@tanstack/react-router";
import { deLocalizeUrl, localizeUrl } from "./paraglide/runtime";
const router = createRouter({
routeTree,
context: { queryClient },
scrollRestoration: true,
defaultPreloadStaleTime: 0,
rewrite: {
input: ({ url }) => deLocalizeUrl(url),
output: ({ url }) => localizeUrl(url),
},
});3.2 Admin app (frontend/apps/admin/src/main.tsx)
Mesma adição no createRouter:
const router = createRouter({
routeTree,
rewrite: {
input: ({ url }) => deLocalizeUrl(url),
output: ({ url }) => localizeUrl(url),
},
});Etapa 4: SSR — paraglideMiddleware (Web App Apenas)
4.1 frontend/apps/web/src/server.ts
Envolver o handler com paraglideMiddleware:
import { paraglideMiddleware } from "./paraglide/server";
async function getServerEntry(): Promise<ServerEntry> {
if (!serverEntryPromise) {
serverEntryPromise = import("@tanstack/react-start/server-entry").then(
(m) => (m.default ?? m) as ServerEntry,
);
}
return serverEntryPromise;
}
export default {
async fetch(request: Request, env: unknown, ctx: unknown) {
try {
// Let Paraglide detect locale, set cookie, etc.
const response = await paraglideMiddleware(request, async ({ request: localizedRequest }) => {
const handler = await getServerEntry();
return handler.fetch(localizedRequest, env, ctx);
});
return await normalizeCatastrophicSsrResponse(response);
} catch (error) {
console.error(error);
Sentry.captureException(error);
return new Response(renderErrorPage(), {
status: 500,
headers: { "content-type": "text/html; charset=utf-8" },
});
}
},
};Etapa 5: Atualizar Root Layout
5.1 Web app (frontend/apps/web/src/routes/__root.tsx)
Alterar <html lang="en"> para getLocale():
import { getLocale } from "@/paraglide/runtime";
function RootShell({ children }: { children: ReactNode }) {
return (
<html lang={getLocale()} suppressHydrationWarning>
...
</html>
);
}Adicionar beforeLoad para redirect offline:
beforeLoad: async () => {
// If not SSR, check locale redirect client-side
if (typeof window !== "undefined") {
const { shouldRedirect } = await import("@/paraglide/runtime");
const decision = await shouldRedirect({ url: window.location.href });
if (decision.redirectUrl) {
throw redirect({ href: decision.redirectUrl.href });
}
}
...
},Etapa 6: Migrar Strings para Mensagens Paraglide
6.1 Estrutura das mensagens
Organizar por domínio (feature):
// frontend/messages/pt-BR.json
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_feed": "Comunicados",
"nav_chat": "Chat",
"nav_events": "Eventos",
"nav_courses": "Cursos",
"nav_members": "Membros",
"nav_leaderboard": "Ranking",
"nav_notifications": "Notificações",
"nav_settings": "Configurações",
"feed_title": "Comunicados",
"feed_empty_title": "Nenhum comunicado ainda",
"feed_empty_description": "Seja o primeiro a compartilhar algo com a comunidade.",
"feed_sort_latest": "Mais recentes",
"feed_sort_popular": "Populares",
"feed_sort_discussed": "Mais comentados",
"not_found_title": "Página não encontrada",
"not_found_description": "A página que você procura não existe ou foi movida.",
"error_title": "Algo deu errado",
"error_description": "Ocorreu um erro inesperado. Tente novamente ou volte para a página inicial.",
// ... muitas outras strings
}6.2 Uso nos componentes
Antes:
<h1>Comunicados</h1>
<button aria-label="New post">+</button>Depois:
import * as m from "@/paraglide/messages";
<h1>{m.feed_title()}</h1>
<button aria-label={m.new_post()}>+</button>6.3 Substituição gradual — por domínio
Sugestão de ordem (do mais usado para o menos crítico):
- Strings compartilhadas (sidebar, nav, botões comuns, layout)
- Página Home/Feed (comunicados, posts)
- Páginas de autenticação (login, register, forgot-password)
- Chat (mensagens, UI de chat)
- Eventos (cards, detalhes)
- Cursos (layout, progresso)
- Configurações (abas, formulários)
- Notificações
- Moderação
- Admin app (strings específicas)
Etapa 7: Componente LanguageSwitcher
Criar componente compartilhado para alternar idioma:
// frontend/apps/web/src/components/LanguageSwitcher.tsx (ou em @repo/ui)
import { locale, locales } from "@/paraglide/runtime";
export function LanguageSwitcher() {
const currentLocale = locale();
return (
<select
value={currentLocale}
onChange={(e) => {
// Paraglide handles the URL rewrite automatically
window.location.href = localizeHref(window.location.pathname, {
locale: e.target.value,
});
}}
aria-label="Select language"
>
{locales.map((l) => (
<option key={l} value={l}>
{l === "pt-BR" ? "Português" : l === "en" ? "English" : l}
</option>
))}
</select>
);
}Etapa 8: Type-Safe Translated Pathnames (Opcional)
Para rotas públicas que precisam de tradução no URL (ex.: /about → /sobre), criar um arquivo de tradução de caminhos:
// frontend/apps/web/src/i18n/pathnames.ts
import type { Locale } from "@/paraglide/runtime";
import type { FileRoutesByTo } from "../routeTree.gen";
type RoutePath = keyof FileRoutesByTo;
// Mapear paths para traduções
export const translatedPathnames: Record<RoutePath, Record<Locale, string>> = {
// Apenas rotas que precisam de URL traduzido
// A maioria das rotas não precisa de tradução no path
};3. Pacotes a Instalar
Dependências principais
# No monorepo frontend (root package.json ou em cada app)
cd frontend
# Instalar Paraglide JS
bun add -D @inlang/paraglide-js
# Instalar o vite plugin
bun add -D @inlang/paraglide-jsO Paraglide é uma devDependency — ele gera código em tempo de build, não roda em produção.
Verificar versões compatíveis
O checkout usa @inlang/paraglide-js@^2.23.0. Verificar se há versão mais recente compatível.
4. Estrutura Final Esperada
frontend/
project.inlang/
settings.json
messages/
pt-BR.json ← source (strings atuais em português)
en.json ← tradução para inglês
apps/web/
src/
paraglide/ ← gerado automaticamente
messages/ ← funções m.* type-safe
runtime.ts ← locale, locales, getLocale, etc.
server.ts ← paraglideMiddleware (apenas SSR)
router.tsx ← rewrite adicionado
server.ts ← paraglideMiddleware wrapper
routes/__root.tsx ← html lang={getLocale()}
components/
LanguageSwitcher.tsx
vite.config.ts ← paraglideVitePlugin adicionado
apps/admin/
src/
paraglide/ ← gerado automaticamente
runtime.ts
messages/
main.tsx ← rewrite adicionado
routes/__root.tsx ← html lang? / configuração
vite.config.ts ← paraglideVitePlugin adicionado5. Considerações Importantes
5.1 Build e Deploy
- O Paraglide gera código em
prebuildvia vite plugin — não precisa de script separado - Garantir que
.gitignoreinclua**/paraglide/(código gerado) - No CI, a build roda normalmente: o vite plugin compila as mensagens
5.2 Cache de Mensagens
- Paraglide gera módulos de mensagem individuais (
outputStructure: "message-modules") — ideal para code splitting - Cada mensagem importada via
import * as m from "@/paraglide/messages"tree-shakes automaticamente
5.3 SEO
- URL locale prefix (
/en/feed) é SEO-friendly <html lang={getLocale()}>para atributo de idioma- Meta tags de idioma no head (opcional: hreflang links)
5.4 Performance
- Paraglide é ~300 bytes gzipped — overhead mínimo
- Mensagens são carregadas sob demanda com code splitting
- Sem runtime pesado
5.5 Testes
- E2E tests em Playwright podem usar
?lang=enquery param (como o checkout faz) para testar locale específico - Testar: alternância de idioma, persistência em cookie, fallback para pt-BR
6. Riscos e Mitigações
| Risco | Mitigação |
|---|---|
paraglideVitePlugin conflitar com tanstackStart | Testar ordem dos plugins; exemplo oficial do TanStack Start + Paraglide usa paraglide antes dos outros plugins |
| SSR + URL rewrite conflitarem | Usar paraglideMiddleware no server.ts; o rewrite no router roda client-side |
| Strings espalhadas em muitos componentes | Abordagem gradual por domínio (Etapa 6.3); criar CSV/planilha para trackear progresso |
| Grande volume de mensagens para traduzir | Foco inicial em pt-BR e en; usar ferramentas como inlang para gerenciar traduções |
| Admin app não tem SSR, locale pode piscar | Usar cookie com sameSite: "lax" para persistir; beforeLoad no root para detectar cookie e redirect |
7. Timeline Sugerida
| Fase | Duração | Entregas |
|---|---|---|
| Setup | 1 dia | project.inlang, mensagens iniciais, vite plugins configurados |
| Integração Router | 1 dia | Rewrite, SSR middleware, root layout |
| Migração Strings Core | 2-3 dias | Nav, sidebar, páginas de auth, layout shell |
| Migração Features | 3-5 dias | Feed, chat, eventos, cursos, configurações |
| Admin App | 1-2 dias | Setup similar + migração das strings específicas |
| LanguageSwitcher | 0.5 dia | Componente + testes |
| Testes E2E | 1 dia | Playwright: locale switching, persistência, fallback |
| Total | ~10-14 dias |
8. Próximos Passos Imediatos
- ✅ Aprovação do plano — revisar e ajustar
- Setup inicial:bash
cd frontend bun add -D @inlang/paraglide-js npx @inlang/paraglide-js@latest init # cria project.inlang/ - Configurar mensagens — extrair strings do app para
messages/pt-BR.json - Configurar vite plugin em ambos os apps
- Adicionar rewrite no router de ambos os apps
- Adicionar paraglideMiddleware no server.ts do web app
- Substituir strings gradualmente nos componentes