Internacionalização em Next.js: implementação de 11 locais
Construir para um público global exige mais do que passar seus textos pelo Google Tradutor. Uma implementação de internacionalização de produção envolve estratégia de roteamento, suporte de layout RTL (da direita para a esquerda), resolução de localidade do lado do servidor, organização de chave de tradução, sinais SEO hreflang e um fluxo de trabalho de tradução sustentável. Se errar qualquer uma dessas opções, você terá layouts quebrados para usuários de árabe/urdu, tags hreflang faltando que dividem suas classificações de pesquisa ou um pipeline de tradução que entra em colapso sob o peso do crescimento do conteúdo.
Este guia documenta a configuração completa de 11 localidades usada em ECOSIRE.COM — inglês mais chinês, espanhol, árabe, português, francês, alemão, japonês, turco, hindi e urdu — baseado no next-intl v4.8. Cada padrão aqui é testado em produção em 5.577 arquivos de conteúdo MDX e 12.543 chaves de tradução.
Principais conclusões
- Use
localePrefix: 'as-needed'— Inglês não tem prefixo, outras localidades recebem/zh/,/ar/, etc.- next-intl v4 usa o padrão
routing.ts+navigation.ts— nunca importe denext/navigationdiretamente em componentes com reconhecimento de localidade- Árabe (
ar) e Urdu (ur) requeremdir="rtl"em<html>e uma fonte compatível com RTL (Noto Sans Arabic)- As chaves
en.jsonDEVEM ser objetos aninhados - chaves planas separadas por pontos quebram a resolução do namespacegenerateMetadata()(nunca estáticoexport const metadata) comalternates.languagespara hreflang em todas as páginas- Os componentes do servidor usam
getTranslations('namespace'), os componentes do cliente usamuseTranslations('namespace')- As páginas de administração usam dois ganchos de tradução:
tpara chaves específicas do módulo,tcpara chavesadmin.commoncompartilhadas- Automatize a tradução de novas chaves com a API do Google Translate; nunca bloqueie o desenvolvimento na tradução manual
Configuração do projeto
pnpm add next-intl@4
Estrutura de diretório para cada aplicativo:
src/
i18n/
routing.ts # defineRouting() — locale list, prefix strategy
navigation.ts # createNavigation() — locale-aware Link, useRouter
request.ts # getRequestConfig() — server-side message loading
messages/
en.json # Source of truth (~12,543 keys)
zh.json
es.json
ar.json
pt.json
fr.json
de.json
ja.json
tr.json
hi.json
ur.json
Configuração de roteamento
// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const locales = [
'en', 'zh', 'es', 'ar', 'pt', 'fr', 'de', 'ja', 'tr', 'hi', 'ur',
] as const;
export type Locale = (typeof locales)[number];
export const rtlLocales: Locale[] = ['ar', 'ur'];
export const localeNames: Record<Locale, string> = {
en: 'English',
zh: '中文',
es: 'Español',
ar: 'العربية',
pt: 'Português',
fr: 'Français',
de: 'Deutsch',
ja: '日本語',
tr: 'Türkçe',
hi: 'हिन्दी',
ur: 'اردو',
};
export const routing = defineRouting({
locales,
defaultLocale: 'en',
localePrefix: 'as-needed', // /en/blog → /blog, /zh/blog → /zh/blog
});
// src/i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
export const { Link, redirect, useRouter, usePathname, getPathname } =
createNavigation(routing);
Middleware/Proxy
next-intl v4 usa uma função de middleware para detecção e roteamento de localidade:
// src/proxy.ts (not middleware.ts — naming matters for Next.js 16)
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
import { NextRequest, NextResponse } from 'next/server';
const intlMiddleware = createMiddleware(routing);
export function middleware(request: NextRequest) {
// Auth protection for dashboard routes
const isDashboard = request.nextUrl.pathname.includes('/dashboard');
const isPortal = request.nextUrl.pathname.includes('/portal');
const authCookie = request.cookies.get('ecosire_auth');
if ((isDashboard || isPortal) && !authCookie) {
const loginUrl = new URL('/auth/login', request.url);
// Prevent open redirect: only allow relative redirect paths
const from = request.nextUrl.pathname;
if (from.startsWith('/') && !from.startsWith('//')) {
loginUrl.searchParams.set('from', from);
}
return NextResponse.redirect(loginUrl);
}
return intlMiddleware(request);
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|.*\\..*|_vercel|favicon.ico).*)',
],
};
Carregamento de mensagens no servidor
// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
// Validate locale is supported; fall back to default
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
Layout de localidade com suporte a RTL e fonte
// src/app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing, rtlLocales, type Locale } from '@/i18n/routing';
import { Noto_Sans, Noto_Sans_Arabic } from 'next/font/google';
const notoSans = Noto_Sans({ subsets: ['latin'], variable: '--font-sans' });
const notoArabic = Noto_Sans_Arabic({
subsets: ['arabic'],
variable: '--font-arabic',
});
export async function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'metadata' });
return {
title: { template: `%s | ${t('siteName')}`, default: t('defaultTitle') },
other: { 'content-language': locale }, // For Bing/AI engine language detection
};
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!routing.locales.includes(locale as Locale)) {
notFound();
}
const messages = await getMessages();
const isRtl = rtlLocales.includes(locale as Locale);
return (
<html
lang={locale}
dir={isRtl ? 'rtl' : 'ltr'}
className={`${notoSans.variable} ${notoArabic.variable}`}
>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
Organização da Chave de Tradução
A estrutura en.json deve usar objetos aninhados, não chaves planas separadas por pontos:
// WRONG — flat keys break next-intl namespace resolution
{
"nav.home": "Home",
"nav.blog": "Blog",
"admin.common.save": "Save"
}
// CORRECT — nested objects
{
"nav": {
"home": "Home",
"blog": "Blog"
},
"admin": {
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"status": "Status"
},
"contacts": {
"title": "Contacts",
"addContact": "Add Contact",
"searchPlaceholder": "Search contacts..."
}
}
}
Componentes do servidor
// Server component: use getTranslations (async)
import { getTranslations } from 'next-intl/server';
export default async function ServicesPage({ params }: Props) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'services.odoo' });
return (
<main>
<h1>{t('hero.title')}</h1>
<p>{t('hero.description')}</p>
</main>
);
}
// generateMetadata must also fetch translations for locale-aware metadata
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'services.odoo' });
return {
title: t('meta.title'),
description: t('meta.description'),
alternates: {
canonical: `https://ecosire.com${locale === 'en' ? '' : `/${locale}`}/services/odoo`,
languages: {
'x-default': 'https://ecosire.com/services/odoo',
en: 'https://ecosire.com/services/odoo',
zh: 'https://ecosire.com/zh/services/odoo',
ar: 'https://ecosire.com/ar/services/odoo',
// ... all 11 locales
},
},
};
}
Componentes do cliente
// Client component: use useTranslations (synchronous)
'use client';
import { useTranslations } from 'next-intl';
// Admin pages use two hooks: module-specific + shared admin.common
export default function ContactsPage() {
const t = useTranslations('admin.contacts'); // Module-specific
const tc = useTranslations('admin.common'); // Shared terms
// IMPORTANT: stateConfig must be inside component (after hooks init)
const stateConfig = {
active: { label: tc('statusActive'), className: 'bg-green-100 text-green-800' },
inactive: { label: tc('statusInactive'), className: 'bg-gray-100 text-gray-700' },
};
return (
<div>
<h1>{t('title')}</h1>
<button>{tc('addNew')}</button>
{/* ... */}
</div>
);
}
Componente de alternância de idioma
// components/language-switcher.tsx
'use client';
import { useLocale } from 'next-intl';
import { usePathname, useRouter } from '@/i18n/navigation'; // NOT next/navigation
import { locales, localeNames } from '@/i18n/routing';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Globe } from 'lucide-react';
export function LanguageSwitcher() {
const locale = useLocale();
const pathname = usePathname();
const router = useRouter();
const handleLocaleChange = (newLocale: string) => {
router.replace(pathname, { locale: newLocale });
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button aria-label="Change language">
<Globe className="h-4 w-4" />
<span>{localeNames[locale as keyof typeof localeNames]}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{locales.map((loc) => (
<DropdownMenuItem
key={loc}
onClick={() => handleLocaleChange(loc)}
className={loc === locale ? 'font-bold' : ''}
>
{localeNames[loc]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
Fluxo de trabalho de tradução
Adicionando novas chaves
- Adicione primeiro a
en.json(fonte da verdade) - Execute o script de tradução:
node apps/web/scripts/translate-missing.mjs
O script encontra chaves presentes em en.json, mas ausentes em outros arquivos de localidade, e chama a API do Google Translate:
// scripts/translate-missing.mjs (simplified)
import { Translate } from '@google-cloud/translate/build/src/v2/index.js';
import { readFileSync, writeFileSync } from 'fs';
const translate = new Translate({ key: process.env.GOOGLE_TRANSLATE_API_KEY });
async function translateMissingKeys(sourceLocale, targetLocale, sourceMessages, targetMessages) {
const missing = findMissingKeys(sourceMessages, targetMessages);
for (const [keyPath, value] of missing) {
const [translated] = await translate.translate(value, targetLocale);
setNestedKey(targetMessages, keyPath, translated);
}
return targetMessages;
}
Validando a cobertura da tradução
# Check for missing keys across all locales
node apps/web/scripts/validate-blog-i18n.mjs
# Fix flat keys accidentally added
node apps/web/scripts/fix-en-flat-keys.mjs
Tradução de conteúdo de blog
Para arquivos de conteúdo MDX, os títulos do frontmatter permanecem em inglês (os títulos estão em JSON i18n), mas o conteúdo do corpo é traduzido por localidade:
src/content/blog/
en/ # English originals (or root directory)
odoo-erp-guide.mdx
zh/
odoo-erp-guide.mdx # Translated body
ar/
odoo-erp-guide.mdx
# ... 10 locale directories
// src/lib/blog.ts — locale-aware loader
export async function getPost(slug: string, locale: string) {
// Try locale-specific file first
const localePath = path.join(contentDir, locale, `${slug}.mdx`);
const englishPath = path.join(contentDir, `${slug}.mdx`);
const filePath = fs.existsSync(localePath) ? localePath : englishPath;
const raw = fs.readFileSync(filePath, 'utf8');
const { data: frontmatter, content } = matter(raw);
return { frontmatter, content, locale };
}
Perguntas frequentes
Por que usar localePrefix: 'as-needed' em vez de always?
Com as-needed, URLs em inglês não têm prefixo (/blog/post), enquanto outras localidades recebem um prefixo (/zh/blog/post). Esta é a convenção padrão – o inglês é o padrão e possui URLs limpos. Com always, o inglês também recebe /en/blog/post, o que cria redirecionamentos desnecessários e divide seu patrimônio de link em inglês existente se você estiver migrando.
Por que as chaves en.json devem ser objetos aninhados e não strings planas separadas por pontos?
A resolução do namespace do next-intl divide a string do namespace por pontos para percorrer a árvore JSON. Se suas chaves forem strings planas separadas por pontos, como "admin.contacts.title", next-intl procura messages.admin.contacts.title como um caminho aninhado, mas descobre que a chave é literalmente a string "admin.contacts.title" no nível raiz, causando uma falha na pesquisa. Sempre use objetos aninhados reais.
Como lidar com a pluralização entre idiomas?
next-intl usa formato de mensagem ICU para pluralização. O inglês tem duas formas plurais (um/outro), o árabe tem seis (zero, um, dois, poucos, muitos, outro). Use a sintaxe {count, plural, one {# item} other {# items}} ICU em seus valores de tradução e next-intl aplica as regras de plural corretas para cada localidade automaticamente.
Posso usar next-intl com o roteador de páginas?
next-intl v4 foi projetado principalmente para o App Router. Para roteador de páginas, use next-intl v2 ou v3. Os padrões diferem significativamente: o Pages Router usa getStaticProps + NextIntlProvider, enquanto o App Router usa getRequestConfig + NextIntlClientProvider no layout. Se você estiver no Pages Router, considere migrar para o App Router antes de implementar o i18n em escala.
Como configuro o suporte RTL para árabe e urdu?
Defina dir="rtl" no elemento <html> quando o código do idioma for ar ou ur. Use o prefixo variante rtl: do Tailwind para estilos específicos de direção (rtl:mr-4 torna-se ml-4 em RTL). Carregue uma fonte compatível – Noto Sans Arabic abrange scripts árabes e urdu. Evite valores CSS left/right codificados; use propriedades lógicas start/end (ms-4, me-4 no Tailwind v4).
Qual é a melhor abordagem para traduzir rapidamente grandes quantidades de conteúdo?
Use a API do Google Translate para a tradução automática inicial de todas as chaves e conteúdo e, em seguida, faça com que falantes nativos revisem o conteúdo de alta visibilidade (página inicial, descrições de produtos, cópia do CTA). Para conteúdo de blog, a tradução automática é aceitável como ponto de partida, mas deve ser revisada quanto à precisão técnica. Orçamento para avaliação de falantes nativos de pelo menos 3 a 5 localidades principais por tráfego.
Próximas etapas
A internacionalização bem feita é um fosso competitivo - seu conteúdo alcança 11 vezes mais clientes em potencial, seus sinais hreflang de SEO se consolidam em vez de dividir o patrimônio do link e os usuários em seu idioma nativo convertem a taxas significativamente mais altas.
ECOSIRE construiu e mantém uma implementação completa de 11 localidades, cobrindo 5.577 arquivos de conteúdo, 12.543 chaves de tradução e suporte a layout RTL. Se você precisar de internacionalização para seu aplicativo Next.js, explore nossos serviços de engenharia de frontend para saber como podemos implementá-lo em sua base de código.
Escrito por
ECOSIRE Research and Development Team
Construindo produtos digitais de nível empresarial na ECOSIRE. Compartilhando insights sobre integrações Odoo, automação de e-commerce e soluções de negócios com IA.
Artigos Relacionados
Next.js 16 App Router: Production Patterns and Pitfalls
Production-ready Next.js 16 App Router patterns: server components, caching strategies, metadata API, error boundaries, and performance pitfalls to avoid.
Expandindo o comércio eletrônico para mercados internacionais: um guia estratégico completo
Guia completo para a expansão internacional do comércio eletrônico, abrangendo pesquisa de mercado, localização, logística, pagamentos, conformidade legal e estratégia de marketing.
ERP para Expansão Internacional: Gerenciando Operações Multipaíses em 2026
Como usar sistemas ERP para gerenciar a expansão internacional, incluindo contabilidade em várias moedas, conformidade fiscal, localização, gerenciamento da cadeia de suprimentos e configuração multiempresa Odoo.