Internationalization in Next.js: 11-Locale Implementation
Building for a global audience requires more than running your strings through Google Translate. A production internationalization implementation involves routing strategy, RTL (right-to-left) layout support, server-side locale resolution, translation key organization, SEO hreflang signals, and a sustainable translation workflow. Get any one of these wrong and you either have broken layouts for Arabic/Urdu users, missing hreflang tags that split your search rankings, or a translation pipeline that collapses under the weight of content growth.
This guide documents the complete 11-locale setup used on ECOSIRE.COM — English plus Chinese, Spanish, Arabic, Portuguese, French, German, Japanese, Turkish, Hindi, and Urdu — built on next-intl v4.8. Every pattern here is production-tested across 5,577 MDX content files and 12,543 translation keys.
Key Takeaways
- Use
localePrefix: 'as-needed'— English has no prefix, other locales get/zh/,/ar/, etc.- next-intl v4 uses
routing.ts+navigation.tspattern — never import fromnext/navigationdirectly in locale-aware components- Arabic (
ar) and Urdu (ur) requiredir="rtl"on<html>and a RTL-compatible font (Noto Sans Arabic)en.jsonkeys MUST be nested objects — flat dot-separated keys break namespace resolutiongenerateMetadata()(never staticexport const metadata) withalternates.languagesfor hreflang on every page- Server components use
getTranslations('namespace'), client components useuseTranslations('namespace')- Admin pages use two translation hooks:
tfor module-specific keys,tcfor sharedadmin.commonkeys- Automate translation of new keys with Google Translate API; never block development on manual translation
Project Setup
pnpm add next-intl@4
Directory structure for each app:
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
Routing Configuration
// 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 uses a middleware function for locale detection and routing:
// 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).*)',
],
};
Server-Side Message Loading
// 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,
};
});
Locale Layout with RTL and Font Support
// 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>
);
}
Translation Key Organization
The en.json structure must use nested objects, not flat dot-separated keys:
// 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..."
}
}
}
Server Components
// 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
},
},
};
}
Client Components
// 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>
);
}
Language Switcher Component
// 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>
);
}
Translation Workflow
Adding New Keys
- Add to
en.jsonfirst (source of truth) - Run the translation script:
node apps/web/scripts/translate-missing.mjs
The script finds keys present in en.json but missing from other locale files and calls the Google Translate API:
// 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;
}
Validating Translation Coverage
# 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
Blog Content Translation
For MDX content files, frontmatter titles stay in English (titles are in i18n JSON), but body content is translated per locale:
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 };
}
Frequently Asked Questions
Why use localePrefix: 'as-needed' instead of always?
With as-needed, English URLs have no prefix (/blog/post), while other locales get a prefix (/zh/blog/post). This is the standard convention — English is the default and has clean URLs. With always, English also gets /en/blog/post, which creates unnecessary redirects and splits your existing English link equity if you are migrating.
Why must en.json keys be nested objects and not flat dot-separated strings?
next-intl's namespace resolution splits the namespace string by dots to traverse the JSON tree. If your keys are flat dot-separated strings like "admin.contacts.title", next-intl looks for messages.admin.contacts.title as a nested path but finds the key is literally the string "admin.contacts.title" at the root level, causing a lookup failure. Always use actual nested objects.
How do I handle pluralization across languages?
next-intl uses ICU message format for pluralization. English has two plural forms (one/other), Arabic has six (zero, one, two, few, many, other). Use the {count, plural, one {# item} other {# items}} ICU syntax in your translation values and next-intl applies the correct plural rules for each locale automatically.
Can I use next-intl with the Pages Router?
next-intl v4 is designed primarily for the App Router. For Pages Router, use next-intl v2 or v3. The patterns differ significantly: Pages Router uses getStaticProps + NextIntlProvider, while App Router uses getRequestConfig + NextIntlClientProvider in the layout. If you are on the Pages Router, consider migrating to App Router before implementing i18n at scale.
How do I set up RTL support for Arabic and Urdu?
Set dir="rtl" on the <html> element when the locale is ar or ur. Use Tailwind's rtl: variant prefix for direction-specific styles (rtl:mr-4 becomes ml-4 in RTL). Load a compatible font — Noto Sans Arabic covers both Arabic and Urdu scripts. Avoid hardcoded left/right CSS values; use start/end logical properties (ms-4, me-4 in Tailwind v4).
What is the best approach for translating large amounts of content quickly?
Use Google Translate API for the initial machine translation of all keys and content, then have native speakers review high-visibility content (homepage, product descriptions, CTA copy). For blog content, machine translation is acceptable as a starting point but should be reviewed for technical accuracy. Budget for native speaker review of at least the top 3-5 locales by traffic.
Next Steps
Internationalization done right is a competitive moat — your content reaches 11x more potential customers, your SEO hreflang signals consolidate rather than split link equity, and users in their native language convert at significantly higher rates.
ECOSIRE has built and maintains a full 11-locale implementation covering 5,577 content files, 12,543 translation keys, and RTL layout support. If you need internationalization for your Next.js application, explore our frontend engineering services to learn how we can implement it for your codebase.
Written by
ECOSIRE Research and Development Team
Building enterprise-grade digital products at ECOSIRE. Sharing insights on Odoo integrations, e-commerce automation, and AI-powered business solutions.
Related Articles
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.
Expanding eCommerce to International Markets: A Complete Strategy Guide
Complete guide to international eCommerce expansion covering market research, localization, logistics, payments, legal compliance, and marketing strategy.
ERP for International Expansion: Managing Multi-Country Operations in 2026
How to use ERP systems to manage international expansion, including multi-currency accounting, tax compliance, localization, supply chain management, and Odoo multi-company configuration.