Skip to content

Plano de Implementação: i18n com Paraglide no Frontend React

Contexto Atual

O monorepo frontend/ contém dois apps React que precisam de i18n:

AppStackSSR?Roteador
apps/webTanStack Start✅ SSR (Cloudflare Workers)@tanstack/react-router v1
apps/adminTanStack 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: source pt-BR, target en
  • Mensagens em checkout/messages/{languageTag}.json
  • Estratégia: ["cookie", "baseLocale"]
  • SSR via paraglideMiddleware em hooks.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 app

Motivação:

  • Mensagens compartilhadas = menos duplicação entre web e admin
  • Cada app tem seu próprio outdir para o código gerado (não conflita)
  • Segue o mesmo padrão do checkout, mas com granularidade de app

1.2 Idiomas

TagIdiomaStatus
pt-BRPortuguês (Brasil)Source (idioma atual do app)
enInglêsPrimeiro target

Futuro: es (Espanhol) pode ser adicionado depois.

1.3 Estratégia de Detecção de Locale

Web app (SSR): ["url", "cookie", "preferredLanguage", "baseLocale"]

  1. ✅ URL: /en/feed, /pt-BR/feed — SEO-friendly, compartilhável
  2. ✅ Cookie: PARAGLIDE_LOCALE — persiste escolha entre visitas
  3. preferredLanguage: detecta Accept-Language do navegador na primeira visita
  4. baseLocale: pt-BR como fallback final

Admin app (client-only): ["url", "cookie", "baseLocale"]

  1. ✅ URL: /en/members, /pt-BR/members
  2. ✅ Cookie: persiste escolha
  3. baseLocale: pt-BR como fallback

2. Etapas de Implementação

Etapa 1: Configuração do Projeto Paraglide

1.1 Inicializar Paraglide no monorepo

bash
cd frontend
npx @inlang/paraglide-js@latest init

Isso cria frontend/project.inlang/settings.json:

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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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():

tsx
import { getLocale } from "@/paraglide/runtime";

function RootShell({ children }: { children: ReactNode }) {
  return (
    <html lang={getLocale()} suppressHydrationWarning>
      ...
    </html>
  );
}

Adicionar beforeLoad para redirect offline:

tsx
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):

json
// 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:

tsx
<h1>Comunicados</h1>
<button aria-label="New post">+</button>

Depois:

tsx
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):

  1. Strings compartilhadas (sidebar, nav, botões comuns, layout)
  2. Página Home/Feed (comunicados, posts)
  3. Páginas de autenticação (login, register, forgot-password)
  4. Chat (mensagens, UI de chat)
  5. Eventos (cards, detalhes)
  6. Cursos (layout, progresso)
  7. Configurações (abas, formulários)
  8. Notificações
  9. Moderação
  10. Admin app (strings específicas)

Etapa 7: Componente LanguageSwitcher

Criar componente compartilhado para alternar idioma:

tsx
// 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:

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

bash
# 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-js

O 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 adicionado

5. Considerações Importantes

5.1 Build e Deploy

  • O Paraglide gera código em prebuild via vite plugin — não precisa de script separado
  • Garantir que .gitignore inclua **/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=en query 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

RiscoMitigação
paraglideVitePlugin conflitar com tanstackStartTestar ordem dos plugins; exemplo oficial do TanStack Start + Paraglide usa paraglide antes dos outros plugins
SSR + URL rewrite conflitaremUsar paraglideMiddleware no server.ts; o rewrite no router roda client-side
Strings espalhadas em muitos componentesAbordagem gradual por domínio (Etapa 6.3); criar CSV/planilha para trackear progresso
Grande volume de mensagens para traduzirFoco inicial em pt-BR e en; usar ferramentas como inlang para gerenciar traduções
Admin app não tem SSR, locale pode piscarUsar cookie com sameSite: "lax" para persistir; beforeLoad no root para detectar cookie e redirect

7. Timeline Sugerida

FaseDuraçãoEntregas
Setup1 diaproject.inlang, mensagens iniciais, vite plugins configurados
Integração Router1 diaRewrite, SSR middleware, root layout
Migração Strings Core2-3 diasNav, sidebar, páginas de auth, layout shell
Migração Features3-5 diasFeed, chat, eventos, cursos, configurações
Admin App1-2 diasSetup similar + migração das strings específicas
LanguageSwitcher0.5 diaComponente + testes
Testes E2E1 diaPlaywright: locale switching, persistência, fallback
Total~10-14 dias

8. Próximos Passos Imediatos

  1. Aprovação do plano — revisar e ajustar
  2. Setup inicial:
    bash
    cd frontend
    bun add -D @inlang/paraglide-js
    npx @inlang/paraglide-js@latest init  # cria project.inlang/
  3. Configurar mensagens — extrair strings do app para messages/pt-BR.json
  4. Configurar vite plugin em ambos os apps
  5. Adicionar rewrite no router de ambos os apps
  6. Adicionar paraglideMiddleware no server.ts do web app
  7. Substituir strings gradualmente nos componentes

Strum — Documentação.