Internationalization in Next.js: 11-Locale Implementation

Build a production-ready 11-locale Next.js app with next-intl v4. Covers routing, RTL support, translation workflows, hreflang SEO, and server/client component patterns.

E
ECOSIRE Research and Development Team
|March 19, 20269 min read1.9k Words|

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.ts pattern — never import from next/navigation directly in locale-aware components
  • Arabic (ar) and Urdu (ur) require dir="rtl" on <html> and a RTL-compatible font (Noto Sans Arabic)
  • en.json keys MUST be nested objects — flat dot-separated keys break namespace resolution
  • generateMetadata() (never static export const metadata) with alternates.languages for hreflang on every page
  • Server components use getTranslations('namespace'), client components use useTranslations('namespace')
  • Admin pages use two translation hooks: t for module-specific keys, tc for shared admin.common keys
  • 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

  1. Add to en.json first (source of truth)
  2. 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.

E

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.

Chat on WhatsApp