DEV Community

Cover image for Como Implementei i18n no Meu Portfólio Next.js com Export Estático (Sem Middleware!)
Pierre Souza
Pierre Souza

Posted on

Como Implementei i18n no Meu Portfólio Next.js com Export Estático (Sem Middleware!)

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
Enter fullscreen mode Exit fullscreen mode

🛠️ 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

💡 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: "🇪🇸",
};
Enter fullscreen mode Exit fullscreen mode

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],
  );
}
Enter fullscreen mode Exit fullscreen mode

🔑 Pontos Importantes:

  1. mounted state: Evita o famoso erro de hydration mismatch. No servidor, sempre retornamos o idioma padrão.

  2. Detecção em cascata: Primeiro localStorage, depois navigator.language, por último o fallback.

  3. 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";
Enter fullscreen mode Exit fullscreen mode

🌐 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

📦 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

⚠️ 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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" },
];
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  ));
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Formatação de datas/números por locale usando Intl
  2. Pluralização para textos como "1 item" vs "2 itens"
  3. Lazy loading dos arquivos de tradução para otimizar bundle size
  4. 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)