Internacionalización en Next.js: Implementación 11-Locale
Construir para una audiencia global requiere más que ejecutar sus cadenas a través del Traductor de Google. Una implementación de internacionalización de producción implica una estrategia de enrutamiento, soporte de diseño RTL (de derecha a izquierda), resolución local del lado del servidor, organización de claves de traducción, señales hreflang de SEO y un flujo de trabajo de traducción sostenible. Si se equivoca en cualquiera de estos, tendrá diseños rotos para los usuarios de árabe/urdu, faltarán etiquetas hreflang que dividen su clasificación de búsqueda o un proceso de traducción que colapsará bajo el peso del crecimiento del contenido.
Esta guía documenta la configuración completa de 11 configuraciones regionales utilizada en ECOSIRE.COM (inglés más chino, español, árabe, portugués, francés, alemán, japonés, turco, hindi y urdu) construida en next-intl v4.8. Cada patrón aquí se prueba en producción en 5577 archivos de contenido MDX y 12,543 claves de traducción.
Conclusiones clave
- Utilice
localePrefix: 'as-needed': el inglés no tiene prefijo, otras configuraciones regionales obtienen/zh/,/ar/, etc.- next-intl v4 usa el patrón
routing.ts+navigation.ts: nunca importe desdenext/navigationdirectamente en componentes compatibles con la configuración regional- El árabe (
ar) y el urdu (ur) requierendir="rtl"en<html>y una fuente compatible con RTL (Noto Sans Arabe)- Las claves
en.jsonDEBEN ser objetos anidados: las claves planas separadas por puntos rompen la resolución del espacio de nombresgenerateMetadata()(nuncaexport const metadataestático) conalternates.languagespara hreflang en cada página- Los componentes del servidor usan
getTranslations('namespace'), los componentes del cliente usanuseTranslations('namespace')- Las páginas de administración utilizan dos enlaces de traducción:
tpara claves específicas del módulo,tcpara clavesadmin.commoncompartidas- Automatizar la traducción de nuevas claves con la API de Google Translate; nunca bloquear el desarrollo en la traducción manual
Configuración del proyecto
pnpm add next-intl@4
Estructura de directorios para cada aplicación:
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
Configuración de enrutamiento
// 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 utiliza una función de middleware para la detección y el enrutamiento de configuración regional:
// 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).*)',
],
};
Carga de mensajes del lado del 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,
};
});
Diseño local con RTL y compatibilidad con fuentes
// 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>
);
}
Organización clave de traducción
La estructura en.json debe utilizar objetos anidados, no claves planas separadas por puntos:
// 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 del 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 del 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 cambio 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>
);
}
Flujo de trabajo de traducción
Agregar nuevas claves
- Agregue primero a
en.json(fuente de la verdad) - Ejecute el script de traducción:
node apps/web/scripts/translate-missing.mjs
El script encuentra claves presentes en en.json pero que faltan en otros archivos locales y llama a la API de 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;
}
Validación de la cobertura de traducción
# 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
Traducción de contenido de blog
Para los archivos de contenido MDX, los títulos iniciales permanecen en inglés (los títulos están en i18n JSON), pero el contenido del cuerpo se traduce según la configuración regional:
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 };
}
Preguntas frecuentes
¿Por qué utilizar localePrefix: 'as-needed' en lugar de always?
Con as-needed, las URL en inglés no tienen prefijo (/blog/post), mientras que otras configuraciones regionales obtienen un prefijo (/zh/blog/post). Esta es la convención estándar: el inglés es el predeterminado y tiene URL limpias. Con always, el inglés también obtiene /en/blog/post, lo que crea redireccionamientos innecesarios y divide el valor de sus enlaces en inglés existentes si está migrando.
¿Por qué las claves en.json deben ser objetos anidados y no cadenas planas separadas por puntos?
La resolución del espacio de nombres de next-intl divide la cadena del espacio de nombres por puntos para atravesar el árbol JSON. Si sus claves son cadenas planas separadas por puntos como "admin.contacts.title", next-intl busca messages.admin.contacts.title como una ruta anidada pero descubre que la clave es literalmente la cadena "admin.contacts.title" en el nivel raíz, lo que provoca un error de búsqueda. Utilice siempre objetos anidados reales.
¿Cómo manejo la pluralización entre idiomas?
next-intl utiliza el formato de mensaje ICU para la pluralización. El inglés tiene dos formas plurales (uno/otro), el árabe tiene seis (cero, uno, dos, pocos, muchos, otros). Utilice la sintaxis {count, plural, one {# item} other {# items}} ICU en sus valores de traducción y next-intl aplica automáticamente las reglas plurales correctas para cada configuración regional.
¿Puedo usar next-intl con Pages Router?
next-intl v4 está diseñado principalmente para App Router. Para Pages Router, utilice next-intl v2 o v3. Los patrones difieren significativamente: Pages Router usa getStaticProps + NextIntlProvider, mientras que App Router usa getRequestConfig + NextIntlClientProvider en el diseño. Si está en Pages Router, considere migrar a App Router antes de implementar i18n a escala.
¿Cómo configuro la compatibilidad con RTL para árabe y urdu?
Establezca dir="rtl" en el elemento <html> cuando la configuración regional sea ar o ur. Utilice el prefijo de variante rtl: de Tailwind para estilos específicos de dirección (rtl:mr-4 se convierte en ml-4 en RTL). Cargue una fuente compatible: Noto Sans Arab cubre escrituras árabe y urdu. Evite los valores CSS left/right codificados; utilice las propiedades lógicas start/end (ms-4, me-4 en Tailwind v4).
¿Cuál es el mejor enfoque para traducir grandes cantidades de contenido rápidamente?
Utilice la API de Google Translate para la traducción automática inicial de todas las claves y el contenido, luego haga que hablantes nativos revisen el contenido de alta visibilidad (página de inicio, descripciones de productos, copia de CTA). Para el contenido de blogs, la traducción automática es aceptable como punto de partida, pero se debe revisar su precisión técnica. Presupuesto para la revisión por parte de hablantes nativos de al menos los 3 a 5 lugares principales por tráfico.
Próximos pasos
La internacionalización bien hecha es un foso competitivo: su contenido llega a 11 veces más clientes potenciales, sus señales SEO hreflang consolidan en lugar de dividir el valor de los enlaces, y los usuarios en su idioma nativo realizan conversiones a tasas significativamente más altas.
ECOSIRE ha creado y mantiene una implementación completa de 11 configuraciones regionales que cubre 5,577 archivos de contenido, 12,543 claves de traducción y soporte de diseño RTL. Si necesita internacionalización para su aplicación Next.js, explore nuestros servicios de ingeniería frontend para saber cómo podemos implementarla para su código base.
Escrito por
ECOSIRE Research and Development Team
Construyendo productos digitales de nivel empresarial en ECOSIRE. Compartiendo perspectivas sobre integraciones Odoo, automatización de eCommerce y soluciones empresariales impulsadas por IA.
Artículos 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.
Expandiendo el comercio electrónico a los mercados internacionales: una guía estratégica completa
Guía completa para la expansión del comercio electrónico internacional que cubre investigación de mercado, localización, logística, pagos, cumplimiento legal y estrategia de marketing.
ERP para la expansión internacional: gestión de operaciones multinacionales en 2026
Cómo utilizar los sistemas ERP para gestionar la expansión internacional, incluida la contabilidad multidivisa, el cumplimiento fiscal, la localización, la gestión de la cadena de suministro y la configuración multiempresa de Odoo.