Recentemente, decidi internacionalizar meu portfólio para alcançar mais pessoas. O problema? Meu projeto usa output: 'export' do Next.js, o que significa zero suporte a middleware.
Depois de bater a cabeça tentando fazer o next-intl funcionar com rotas dinâmicas, encontrei uma solução funcional usando apenas React Context. Nesse artigo, vou compartilhar exatamente como fiz isso funcionar.
🎯 O Que Vamos Construir
Ao final deste tutorial, você terá:
- ✅ Suporte a múltiplos idiomas (PT, EN, ES)
- ✅ Detecção automática do idioma do navegador
- ✅ Persistência da escolha via localStorage
- ✅ Troca instantânea sem reload da página
- ✅ 100% compatível com sites estáticos
🤔 Por Que Não Usar next-intl com Middleware?
Se você está usando output: 'export', já deve ter descoberto que middleware não funciona. O Next.js gera arquivos estáticos puros, sem servidor para processar redirecionamentos baseados em headers.
A maioria dos tutoriais de i18n assume que você tem um servidor rodando. Nós não temos esse luxo.
A solução? Um sistema 100% client-side usando React Context.
📁 Estrutura do Projeto
Antes de começar, aqui está como organizei os arquivos:
├── lib/
│ └── i18n/
│ ├── config.ts # Configurações de idiomas
│ ├── LocaleProvider.tsx # O coração do sistema
│ └── index.ts # Exports centralizados
├── messages/
│ ├── pt.json # 🇧🇷 Português
│ ├── en.json # 🇺🇸 English
│ └── es.json # 🇪🇸 Español
├── components/
│ └── LanguageSwitcher.tsx # Dropdown de seleção
└── app/
└── layout.tsx # Onde tudo se conecta
🛠️ Passo 1: Arquivos de Tradução
Primeiro, crie a pasta messages/ na raiz do projeto. Cada arquivo JSON representa um idioma:
messages/pt.json
{
"common": {
"seeMore": "Ver mais",
"description": "Descrição",
"techs": "Tecnologias"
},
"header": {
"home": "Início",
"about": "Sobre",
"experience": "Experiência",
"projects": "Projetos"
},
"home": {
"greeting": "Olá, eu sou",
"role": "Desenvolvedor Front-end"
},
"language": {
"select": "Idioma"
}
}
messages/en.json
{
"common": {
"seeMore": "See more",
"description": "Description",
"techs": "Technologies"
},
"header": {
"home": "Home",
"about": "About",
"experience": "Experience",
"projects": "Projects"
},
"home": {
"greeting": "Hi, I'm",
"role": "Front-end Developer"
},
"language": {
"select": "Language"
}
}
💡 Dica: Organize suas traduções por namespace (common, header, home, etc.). Isso facilita a manutenção quando o projeto crescer.
⚙️ Passo 2: Configuração Base
Crie o arquivo de configuração com os idiomas suportados:
lib/i18n/config.ts
export const locales = ["pt", "en", "es"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "pt";
export const localeNames: Record<Locale, string> = {
pt: "Português",
en: "English",
es: "Español",
};
export const localeFlags: Record<Locale, string> = {
pt: "🇧🇷",
en: "🇺🇸",
es: "🇪🇸",
};
Usar as const garante que o TypeScript trate o array como uma tupla literal, permitindo inferir o tipo Locale automaticamente.
🧠 Passo 3: O Provider (O Coração do Sistema)
Aqui está onde a mágica acontece. Este é o componente mais importante:
lib/i18n/LocaleProvider.tsx
"use client";
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from "react";
import { Locale, defaultLocale, locales } from "./config";
// Importa todos os arquivos de tradução
import ptMessages from "../../messages/pt.json";
import enMessages from "../../messages/en.json";
import esMessages from "../../messages/es.json";
type Messages = typeof ptMessages;
const messagesMap: Record<Locale, Messages> = {
pt: ptMessages,
en: enMessages,
es: esMessages,
};
// 🔍 Detecta o idioma do navegador automaticamente
function detectBrowserLocale(): Locale {
if (typeof window === "undefined") return defaultLocale;
// Primeiro: verifica se o usuário já escolheu um idioma
const savedLocale = localStorage.getItem("locale") as Locale | null;
if (savedLocale && locales.includes(savedLocale)) {
return savedLocale;
}
// Segundo: detecta do navegador
const browserLang = navigator.language.toLowerCase();
if (browserLang.startsWith("pt")) return "pt";
if (browserLang.startsWith("es")) return "es";
if (browserLang.startsWith("en")) return "en";
return defaultLocale;
}
interface LocaleContextType {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: string) => string;
messages: Messages;
}
const LocaleContext = createContext<LocaleContextType | null>(null);
export function LocaleProvider({ children }: { children: React.ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(defaultLocale);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setLocaleState(detectBrowserLocale());
setMounted(true);
}, []);
const setLocale = useCallback((newLocale: Locale) => {
setLocaleState(newLocale);
localStorage.setItem("locale", newLocale);
}, []);
const messages = messagesMap[locale];
const t = useCallback(
(key: string): string => {
const keys = key.split(".");
let value: unknown = messages;
for (const k of keys) {
if (value && typeof value === "object" && k in value) {
value = (value as Record<string, unknown>)[k];
} else {
return key; // Retorna a chave se não encontrar
}
}
return typeof value === "string" ? value : key;
},
[messages],
);
// ⚠️ Evita hydration mismatch
if (!mounted) {
return (
<LocaleContext.Provider
value={{ locale: defaultLocale, setLocale, t, messages: ptMessages }}
>
{children}
</LocaleContext.Provider>
);
}
return (
<LocaleContext.Provider value={{ locale, setLocale, t, messages }}>
{children}
</LocaleContext.Provider>
);
}
export function useLocale() {
const context = useContext(LocaleContext);
if (!context) {
throw new Error("useLocale must be used within a LocaleProvider");
}
return context;
}
// Hook com suporte a namespace
export function useTranslations(namespace?: string) {
const { messages } = useLocale();
return useCallback(
(key: string): string => {
const fullKey = namespace ? `${namespace}.${key}` : key;
const keys = fullKey.split(".");
let value: unknown = messages;
for (const k of keys) {
if (value && typeof value === "object" && k in value) {
value = (value as Record<string, unknown>)[k];
} else {
return key;
}
}
return typeof value === "string" ? value : key;
},
[messages, namespace],
);
}
🔑 Pontos Importantes:
mountedstate: Evita o famoso erro de hydration mismatch. No servidor, sempre retornamos o idioma padrão.Detecção em cascata: Primeiro localStorage, depois navigator.language, por último o fallback.
Type safety: O TypeScript nos ajuda a garantir que as chaves de tradução existam.
🔗 Passo 4: Barrel Export
Centralize os exports para imports mais limpos:
lib/i18n/index.ts
export { LocaleProvider, useLocale, useTranslations } from "./LocaleProvider";
export { locales, defaultLocale, localeNames, localeFlags } from "./config";
export type { Locale } from "./config";
🌐 Passo 5: Componente de Troca de Idioma
Um dropdown elegante para o usuário escolher o idioma:
components/LanguageSwitcher.tsx
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { useLocale, locales, localeNames, localeFlags } from "@/lib/i18n";
import { IoLanguage } from "react-icons/io5";
export function LanguageSwitcher() {
const { locale, setLocale } = useLocale();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-1">
<IoLanguage className="h-4 w-4" />
<span className="text-xs font-medium uppercase">{locale}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{locales.map((loc) => (
<DropdownMenuItem
key={loc}
onClick={() => setLocale(loc)}
className={locale === loc ? "bg-accent" : ""}
>
<span className="mr-2">{localeFlags[loc]}</span>
{localeNames[loc]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
📦 Este exemplo usa shadcn/ui e react-icons, mas você pode adaptar para qualquer biblioteca de componentes.
🏗️ Passo 6: Integração no Layout
Agora, conecte tudo no layout principal:
app/layout.tsx
import { LocaleProvider } from "@/lib/i18n";
import { ThemeProvider } from "@/components/theme-provider";
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="pt" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<LocaleProvider>
<Header />
<main>{children}</main>
<Footer />
</LocaleProvider>
</ThemeProvider>
</body>
</html>
);
}
⚠️ Erro comum: Colocar providers fora do
<body>causa erros de script. Sempre dentro!
✨ Passo 7: Usando nas Páginas
Agora vem a parte divertida - usar as traduções:
"use client";
import { useTranslations } from "@/lib/i18n";
export default function AboutPage() {
const t = useTranslations("about");
const tc = useTranslations("common");
return (
<section>
<h1>{t("title")}</h1>
<p>{t("description")}</p>
<button>{tc("seeMore")}</button>
</section>
);
}
Simples assim! O hook useTranslations aceita um namespace opcional para organizar suas chamadas.
🎨 Bônus: Traduzindo Conteúdo Dinâmico
Para arrays de dados (projetos, experiências, etc.), adicione uma translationKey:
mock.ts
export interface Project {
id: string;
translationKey: string;
// outros campos...
}
export const projects: Project[] = [
{ id: "1", translationKey: "portfolio" },
{ id: "2", translationKey: "ecommerce" },
];
messages/pt.json
{
"projectItems": {
"portfolio": {
"title": "Meu Portfólio",
"description": "Site pessoal construído com Next.js"
},
"ecommerce": {
"title": "E-commerce",
"description": "Loja virtual completa"
}
}
}
Na página:
const tp = useTranslations("projectItems");
{
projects.map((project) => (
<div key={project.id}>
<h2>{tp(`${project.translationKey}.title`)}</h2>
<p>{tp(`${project.translationKey}.description`)}</p>
</div>
));
}
📊 Prós e Contras
✅ Vantagens
| Benefício | Descrição |
|---|---|
| Zero servidor | Funciona em qualquer CDN estática |
| Instantâneo | Troca sem reload da página |
| Persistente | Lembra a escolha do usuário |
| Automático | Detecta idioma do navegador |
| Type-safe | TypeScript garante chaves válidas |
❌ Desvantagens
| Limitação | Workaround |
|---|---|
| SEO limitado | Conteúdo inicial sempre no idioma padrão |
| Flash de conteúdo | Minimizado com estado mounted
|
🚀 Próximos Passos
Algumas melhorias que você pode fazer:
-
Formatação de datas/números por locale usando
Intl - Pluralização para textos como "1 item" vs "2 itens"
- Lazy loading dos arquivos de tradução para otimizar bundle size
- Validação de chaves em tempo de build com TypeScript
🎉 Conclusão
Implementar i18n em sites estáticos Next.js não precisa ser complicado. Com React Context e um pouco de organização, você consegue um sistema robusto e fácil de manter.
O código completo está disponível no meu GitHub. Se tiver dúvidas ou sugestões, deixe nos comentários!
Gostou do artigo? Deixe um ❤️ e me siga para mais conteúdo sobre React e Next.js!
Top comments (0)