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
|19 مارس 20269 دقائق قراءة2.0k كلمات|

التدويل في Next.js: 11-التنفيذ المحلي

يتطلب بناء جمهور عالمي أكثر من مجرد تشغيل سلاسلك من خلال خدمة الترجمة من Google. يتضمن تنفيذ تدويل الإنتاج استراتيجية التوجيه، ودعم تخطيط RTL (من اليمين إلى اليسار)، ودقة اللغة من جانب الخادم، وتنظيم مفتاح الترجمة، وإشارات SEO hreflang، وسير عمل ترجمة مستدام. إذا أخطأت في أي من هذه الأخطاء، فإما أن يكون لديك تخطيطات معطلة للمستخدمين باللغة العربية/الأردية، أو تفتقد علامات hreflang التي تؤدي إلى تقسيم تصنيفات البحث الخاصة بك، أو أن مسار الترجمة ينهار تحت وطأة نمو المحتوى.

يوثق هذا الدليل الإعداد الكامل المكون من 11 لغة المستخدمة في ECOSIRE.COM - الإنجليزية بالإضافة إلى الصينية والإسبانية والعربية والبرتغالية والفرنسية والألمانية واليابانية والتركية والهندية والأردية - المبنية على الإصدار التالي من intl v4.8. تم اختبار كل نمط هنا إنتاجيًا عبر 5,577 ملف محتوى MDX و12,543 مفتاح ترجمة.

الوجبات الرئيسية

  • استخدم localePrefix: 'as-needed' - لا تحتوي اللغة الإنجليزية على بادئة، بينما تحصل اللغات الأخرى على /zh/، /ar/، وما إلى ذلك.
  • يستخدم الإصدار التالي من intl v4 نمط routing.ts + navigation.ts - لا يتم الاستيراد مطلقًا من next/navigation مباشرة في المكونات المدركة للغة
  • العربية (ar) والأردية (ur) تتطلب dir="rtl" على <html> وخطًا متوافقًا مع RTL (Noto Sans Arab)
  • يجب أن تكون مفاتيح en.json كائنات متداخلة - المفاتيح المسطحة المفصولة بالنقاط تكسر دقة مساحة الاسم
  • generateMetadata() (ليس ثابتًا أبدًا export const metadata) مع alternates.languages لـ hreflang في كل صفحة
  • تستخدم مكونات الخادم getTranslations('namespace')، وتستخدم مكونات العميل useTranslations('namespace')
  • تستخدم صفحات الإدارة خطافين للترجمة: t للمفاتيح الخاصة بالوحدة النمطية، tc لمفاتيح admin.common المشتركة
  • الترجمة الآلية للمفاتيح الجديدة باستخدام Google Translate API؛ لا تمنع أبدًا تطوير الترجمة اليدوية

إعداد المشروع

pnpm add next-intl@4

بنية الدليل لكل تطبيق:

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

تكوين التوجيه

// 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);

الوسيطة / الوكيل

يستخدم next-intl v4 وظيفة وسيطة للكشف عن الإعدادات المحلية وتوجيهها:

// 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).*)',
  ],
};

تحميل الرسائل من جانب الخادم

// 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,
  };
});

تخطيط اللغة مع دعم RTL والخط

// 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>
  );
}

تنظيم مفتاح الترجمة

يجب أن تستخدم بنية en.json كائنات متداخلة، وليس مفاتيح مسطحة مفصولة بنقاط:

// 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 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 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>
  );
}

مكون محول اللغة

// 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>
  );
}

سير عمل الترجمة

إضافة مفاتيح جديدة

  1. أضف إلى en.json أولاً (مصدر الحقيقة)
  2. قم بتشغيل البرنامج النصي للترجمة:
node apps/web/scripts/translate-missing.mjs

يعثر البرنامج النصي على المفاتيح الموجودة في en.json ولكنها مفقودة من الملفات المحلية الأخرى ويستدعي 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;
}

التحقق من صحة تغطية الترجمة

# 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

ترجمة محتوى المدونة

بالنسبة لملفات محتوى MDX، تظل العناوين الأمامية باللغة الإنجليزية (العناوين مكتوبة بـ i18n JSON)، ولكن تتم ترجمة محتوى النص لكل لغة:

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 };
}

الأسئلة المتداولة

لماذا تستخدم localePrefix: 'as-needed' بدلاً من always؟

مع as-needed، لا تحتوي عناوين URL الإنجليزية على بادئة (/blog/post)، بينما تحصل اللغات الأخرى على بادئة (/zh/blog/post). هذا هو الاصطلاح القياسي — اللغة الإنجليزية هي اللغة الافتراضية ولها عناوين URL نظيفة. مع always، تحصل اللغة الإنجليزية أيضًا على /en/blog/post، مما يؤدي إلى إنشاء عمليات إعادة توجيه غير ضرورية وتقسيم حقوق الارتباط الحالية باللغة الإنجليزية إذا كنت تقوم بالترحيل.

لماذا يجب أن تكون مفاتيح en.json كائنات متداخلة وليست سلاسل مسطحة مفصولة بنقاط؟

يقوم حل مساحة الاسم الخاص بـ next-intl بتقسيم سلسلة مساحة الاسم بالنقاط لاجتياز شجرة JSON. إذا كانت مفاتيحك عبارة عن سلاسل مسطحة مفصولة بنقاط مثل "admin.contacts.title"، فإن next-intl يبحث عن messages.admin.contacts.title كمسار متداخل ولكنه يجد أن المفتاح هو السلسلة "admin.contacts.title" حرفيًا على مستوى الجذر، مما يتسبب في فشل البحث. استخدم دائمًا الكائنات المتداخلة الفعلية.

كيف أتعامل مع صيغة الجمع عبر اللغات؟

يستخدم next-intl تنسيق رسالة ICU للجمع. اللغة الإنجليزية لها صيغتي جمع (واحد/آخر)، والعربية لها ستة (صفر، واحد، اثنان، قليل، كثير، آخر). استخدم بناء جملة {count, plural, one {# item} other {# items}} ICU في قيم الترجمة الخاصة بك وسيطبق next-intl قواعد الجمع الصحيحة لكل لغة تلقائيًا.

هل يمكنني استخدام next-intl مع جهاز توجيه الصفحات؟

تم تصميم next-intl v4 بشكل أساسي لجهاز توجيه التطبيقات. بالنسبة لجهاز توجيه الصفحات، استخدم next-intl v2 أو v3. تختلف الأنماط بشكل كبير: يستخدم جهاز توجيه الصفحات getStaticProps + NextIntlProvider، بينما يستخدم جهاز توجيه التطبيقات getRequestConfig + NextIntlClientProvider في التخطيط. إذا كنت تستخدم جهاز توجيه الصفحات، ففكر في الانتقال إلى جهاز توجيه التطبيقات قبل تنفيذ i18n على نطاق واسع.

كيف أقوم بإعداد دعم RTL للغتين العربية والأردية؟

قم بتعيين dir="rtl" على العنصر <html> عندما تكون الإعدادات المحلية هي ar أو ur. استخدم البادئة المتغيرة rtl: الخاصة بـ Tailwind للأنماط الخاصة بالاتجاه (rtl:mr-4 تصبح ml-4 في RTL). قم بتحميل خط متوافق - يغطي Noto Sans Arab النصوص العربية والأردية. تجنب قيم left/right المضمنة في CSS؛ استخدم start/end الخصائص المنطقية (ms-4، me-4 في Tailwind v4).

ما هو الأسلوب الأفضل لترجمة كميات كبيرة من المحتوى بسرعة؟

استخدم Google Translate API للترجمة الآلية الأولية لجميع المفاتيح والمحتوى، ثم اطلب من المتحدثين الأصليين مراجعة المحتوى عالي الوضوح (الصفحة الرئيسية، أوصاف المنتج، نسخة CTA). بالنسبة لمحتوى المدونة، تعتبر الترجمة الآلية مقبولة كنقطة بداية ولكن يجب مراجعتها للتأكد من دقتها الفنية. ميزانية لمراجعة المتحدث الأصلي لأفضل 3-5 لغات على الأقل حسب حركة المرور.


الخطوات التالية

إن التدويل الذي يتم بشكل صحيح هو بمثابة خندق تنافسي - حيث يصل المحتوى الخاص بك إلى عدد أكبر من العملاء المحتملين بمقدار 11 مرة، وتدمج إشارات SEO hreflang الخاصة بك بدلاً من تقسيم حقوق الارتباط، ويتحول المستخدمون بلغتهم الأم بمعدلات أعلى بكثير.

قامت ECOSIRE ببناء وصيانة تطبيق كامل مكون من 11 لغة يغطي 5577 ملف محتوى و12543 مفتاح ترجمة ودعم تخطيط RTL. إذا كنت بحاجة إلى تدويل تطبيق Next.js الخاص بك، استكشف خدماتنا الهندسية للواجهة الأمامية لمعرفة كيف يمكننا تنفيذ ذلك لقاعدة التعليمات البرمجية الخاصة بك.

E

بقلم

ECOSIRE Research and Development Team

بناء منتجات رقمية بمستوى المؤسسات في ECOSIRE. مشاركة رؤى حول تكاملات Odoo وأتمتة التجارة الإلكترونية وحلول الأعمال المدعومة بالذكاء الاصطناعي.

الدردشة على الواتساب