إطار Next.js

التدويل (i18n) في Next.js

40 دقيقة الدرس 22 من 40

فهم التدويل في Next.js

التدويل (i18n) هو عملية إعداد تطبيقك لدعم لغات ومناطق متعددة. توفر Next.js ميزات قوية مدمجة لإنشاء تطبيقات متعددة اللغات مع توجيه اللغة والكشف التلقائي عن اللغة والترجمات السلسة.

توجيه i18n في Next.js

تدعم Next.js استراتيجيتين للتوجيه للتدويل:

  • توجيه المسار الفرعي: /en/about، /ar/about
  • توجيه النطاق: example.com، example.ar
مهم: في App Router، يتم التعامل مع توجيه i18n يدوياً من خلال middleware وبنية المجلدات، مما يمنحك مرونة وتحكم أكبر.

إعداد i18n مع App Router

أولاً، قم بإنشاء بنية مجلدات للغاتك:

app/
├── [locale]/
│   ├── layout.tsx
│   ├── page.tsx
│   ├── about/
│   │   └── page.tsx
│   └── products/
│       └── page.tsx
└── middleware.ts

إنشاء تكوين i18n

قم بإنشاء ملف تكوين للغات المدعومة:

// i18n.config.ts
export const i18n = {
  defaultLocale: 'en',
  locales: ['en', 'ar', 'fr', 'es'],
} as const;

export type Locale = (typeof i18n)['locales'][number];

// أسماء اللغات للعرض
export const localeNames: Record<Locale, string> = {
  en: 'English',
  ar: 'العربية',
  fr: 'Français',
  es: 'Español',
};

// اللغات RTL
export const rtlLocales: Locale[] = ['ar'];

Middleware للكشف عن اللغة

قم بإنشاء middleware للتعامل مع الكشف عن اللغة وإعادة التوجيه:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { i18n } from './i18n.config';

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  // تحقق من وجود اللغة في المسار
  const pathnameIsMissingLocale = i18n.locales.every(
    (locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
  );

  // إعادة التوجيه إذا لم تكن هناك لغة
  if (pathnameIsMissingLocale) {
    const locale = getLocale(request);
    return NextResponse.redirect(
      new URL(`/${locale}${pathname}`, request.url)
    );
  }
}

function getLocale(request: NextRequest): string {
  // 1. تحقق من cookie
  const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
  if (cookieLocale && i18n.locales.includes(cookieLocale as any)) {
    return cookieLocale;
  }

  // 2. تحقق من رأس Accept-Language
  const acceptLanguage = request.headers.get('accept-language');
  if (acceptLanguage) {
    const preferredLocale = acceptLanguage
      .split(',')
      .map((lang) => lang.split(';')[0].trim().split('-')[0])
      .find((lang) => i18n.locales.includes(lang as any));

    if (preferredLocale) {
      return preferredLocale;
    }
  }

  // 3. اللغة الافتراضية
  return i18n.defaultLocale;
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

التخطيط الجذري مع اللغة

قم بإنشاء تخطيط ديناميكي يتعامل مع الإعدادات الخاصة باللغة:

// app/[locale]/layout.tsx
import { Inter } from 'next/font/google';
import { Locale, i18n, rtlLocales } from '@/i18n.config';
import { notFound } from 'next/navigation';

const inter = Inter({ subsets: ['latin'] });

export async function generateStaticParams() {
  return i18n.locales.map((locale) => ({ locale }));
}

export default function LocaleLayout({
  children,
  params: { locale },
}: {
  children: React.ReactNode;
  params: { locale: Locale };
}) {
  // التحقق من صحة اللغة
  if (!i18n.locales.includes(locale)) {
    notFound();
  }

  const isRTL = rtlLocales.includes(locale);

  return (
    <html lang={locale} dir={isRTL ? 'rtl' : 'ltr'}>
      <body className={inter.className}>
        {children}
      </body>
    </html>
  );
}

إنشاء قواميس الترجمة

نظم ترجماتك في ملفات منفصلة:

// dictionaries/en.json
{
  "navigation": {
    "home": "Home",
    "about": "About",
    "products": "Products",
    "contact": "Contact"
  },
  "home": {
    "title": "Welcome to Our Website",
    "description": "Discover amazing products and services",
    "cta": "Get Started"
  },
  "products": {
    "title": "Our Products",
    "featured": "Featured Products",
    "viewDetails": "View Details",
    "addToCart": "Add to Cart"
  },
  "common": {
    "loading": "Loading...",
    "error": "Something went wrong",
    "retry": "Try Again",
    "learnMore": "Learn More"
  }
}

// dictionaries/ar.json
{
  "navigation": {
    "home": "الرئيسية",
    "about": "من نحن",
    "products": "المنتجات",
    "contact": "اتصل بنا"
  },
  "home": {
    "title": "مرحباً بك في موقعنا",
    "description": "اكتشف منتجات وخدمات مذهلة",
    "cta": "ابدأ الآن"
  },
  "products": {
    "title": "منتجاتنا",
    "featured": "المنتجات المميزة",
    "viewDetails": "عرض التفاصيل",
    "addToCart": "أضف إلى السلة"
  },
  "common": {
    "loading": "جاري التحميل...",
    "error": "حدث خطأ ما",
    "retry": "حاول مرة أخرى",
    "learnMore": "اعرف المزيد"
  }
}

دالة تحميل القاموس

قم بإنشاء دالة لتحميل الترجمات:

// lib/dictionaries.ts
import 'server-only';
import type { Locale } from '@/i18n.config';

const dictionaries = {
  en: () => import('@/dictionaries/en.json').then((module) => module.default),
  ar: () => import('@/dictionaries/ar.json').then((module) => module.default),
  fr: () => import('@/dictionaries/fr.json').then((module) => module.default),
  es: () => import('@/dictionaries/es.json').then((module) => module.default),
};

export const getDictionary = async (locale: Locale) => {
  return dictionaries[locale]();
};
نصيحة: تضمن حزمة 'server-only' أن تحميل القاموس يحدث فقط على الخادم، مما يمنع تجميع ملفات الترجمة في JavaScript من جانب العميل.

استخدام الترجمات في مكونات الخادم

الوصول إلى الترجمات في مكونات الخادم الخاصة بك:

// app/[locale]/page.tsx
import { getDictionary } from '@/lib/dictionaries';
import { Locale } from '@/i18n.config';

export default async function HomePage({ params }: { params: { locale: Locale } }) {
  const dict = await getDictionary(params.locale);

  return (
    <main>
      <h1>{dict.home.title}</h1>
      <p>{dict.home.description}</p>
      <button>{dict.home.cta}</button>
    </main>
  );
}

مكون مبدل اللغة

قم بإنشاء مكون للتبديل بين اللغات:

// components/LanguageSwitcher.tsx
'use client';

import { usePathname, useRouter } from 'next/navigation';
import { i18n, localeNames, type Locale } from '@/i18n.config';
import { useState } from 'react';

export default function LanguageSwitcher({ currentLocale }: { currentLocale: Locale }) {
  const pathname = usePathname();
  const router = useRouter();
  const [isOpen, setIsOpen] = useState(false);

  const switchLocale = (locale: Locale) => {
    // إزالة اللغة الحالية من المسار
    const segments = pathname.split('/');
    segments[1] = locale;
    const newPath = segments.join('/');

    // تعيين cookie لتفضيل اللغة
    document.cookie = `NEXT_LOCALE=${locale}; path=/; max-age=31536000`;

    router.push(newPath);
    setIsOpen(false);
  };

  return (
    <div className="language-switcher">
      <button onClick={() => setIsOpen(!isOpen)}>
        {localeNames[currentLocale]}
      </button>

      {isOpen && (
        <ul>
          {i18n.locales.map((locale) => (
            <li key={locale}>
              <button
                onClick={() => switchLocale(locale)}
                className={locale === currentLocale ? 'active' : ''}
              >
                {localeNames[locale]}
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

دعم RTL

أضف CSS لدعم اللغات من اليمين إلى اليسار:

/* styles/rtl.css */

/* تعديلات RTL العامة */
html[dir='rtl'] {
  text-align: right;
}

html[dir='rtl'] .container {
  direction: rtl;
}

/* التنقل */
html[dir='rtl'] .nav-list {
  flex-direction: row-reverse;
}

/* محاذاة النص */
html[dir='rtl'] p,
html[dir='rtl'] h1,
html[dir='rtl'] h2,
html[dir='rtl'] h3 {
  text-align: right;
}

/* الهوامش والحشو */
html[dir='rtl'] .ml-4 {
  margin-left: 0;
  margin-right: 1rem;
}

html[dir='rtl'] .mr-4 {
  margin-right: 0;
  margin-left: 1rem;
}

/* الخصائص المنطقية (نهج أفضل) */
.spacing {
  margin-inline-start: 1rem;
  padding-inline-end: 2rem;
}
النهج الحديث: استخدم خصائص CSS المنطقية (margin-inline-start، padding-inline-end) بدلاً من الخصائص الاتجاهية (margin-left، padding-right) للحصول على دعم RTL تلقائي.

الروابط الواعية باللغة

قم بإنشاء غلاف لمكون Link في Next.js يحافظ على اللغة:

// components/LocaleLink.tsx
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Locale } from '@/i18n.config';

interface LocaleLinkProps {
  href: string;
  children: React.ReactNode;
  className?: string;
}

export default function LocaleLink({ href, children, className }: LocaleLinkProps) {
  const params = useParams();
  const locale = params?.locale as Locale;

  // إضافة اللغة إلى href إذا لم تكن موجودة
  const localizedHref = href.startsWith('/')
    ? `/${locale}${href}`
    : `/${locale}/${href}`;

  return (
    <Link href={localizedHref} className={className}>
      {children}
    </Link>
  );
}

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

التعامل مع الترجمات للمحتوى من APIs أو قواعد البيانات:

// lib/translations.ts
import { Locale } from '@/i18n.config';

interface TranslatableContent {
  title: {
    en: string;
    ar: string;
    fr: string;
    es: string;
  };
  description: {
    en: string;
    ar: string;
    fr: string;
    es: string;
  };
}

export function getTranslatedContent<T extends TranslatableContent>(
  content: T,
  locale: Locale
): { title: string; description: string } {
  return {
    title: content.title[locale],
    description: content.description[locale],
  };
}

// الاستخدام في المكون
const product = {
  id: 1,
  title: {
    en: 'Laptop',
    ar: 'حاسوب محمول',
    fr: 'Ordinateur portable',
    es: 'Portátil',
  },
  description: {
    en: 'High-performance laptop',
    ar: 'حاسوب محمول عالي الأداء',
    fr: 'Ordinateur portable haute performance',
    es: 'Portátil de alto rendimiento',
  },
};

const translated = getTranslatedContent(product, 'ar');
// { title: 'حاسوب محمول', description: 'حاسوب محمول عالي الأداء' }

تنسيق الأرقام والتواريخ

استخدم Intl API لتنسيق واعي باللغة:

// lib/formatting.ts
import { Locale } from '@/i18n.config';

export function formatNumber(value: number, locale: Locale): string {
  return new Intl.NumberFormat(locale).format(value);
}

export function formatCurrency(value: number, locale: Locale, currency = 'USD'): string {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
  }).format(value);
}

export function formatDate(date: Date, locale: Locale): string {
  return new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  }).format(date);
}

export function formatRelativeTime(date: Date, locale: Locale): string {
  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
  const diffInDays = Math.floor((date.getTime() - Date.now()) / (1000 * 60 * 60 * 24));

  return rtf.format(diffInDays, 'day');
}

// الاستخدام
formatNumber(1234567.89, 'ar'); // "١٬٢٣٤٬٥٦٧٫٨٩"
formatCurrency(99.99, 'en', 'USD'); // "$99.99"
formatDate(new Date(), 'ar'); // "١٤ فبراير ٢٠٢٦"
formatRelativeTime(new Date(Date.now() - 86400000), 'en'); // "yesterday"

تحسين محركات البحث مع hreflang

أضف روابط اللغات البديلة لتحسين محركات البحث:

// app/[locale]/layout.tsx
import { Metadata } from 'next';
import { i18n, Locale } from '@/i18n.config';

export async function generateMetadata({
  params,
}: {
  params: { locale: Locale };
}): Promise<Metadata> {
  const baseUrl = 'https://example.com';

  return {
    alternates: {
      canonical: `${baseUrl}/${params.locale}`,
      languages: Object.fromEntries(
        i18n.locales.map((locale) => [locale, `${baseUrl}/${locale}`])
      ),
    },
  };
}

Hook مساعد الترجمة

قم بإنشاء hook من جانب العميل للترجمات:

// hooks/useTranslations.ts
'use client';

import { useParams } from 'next/navigation';
import { Locale } from '@/i18n.config';
import { createContext, useContext, ReactNode } from 'react';

type Dictionary = Record<string, any>;

const TranslationsContext = createContext<Dictionary | null>(null);

export function TranslationsProvider({
  children,
  dictionary,
}: {
  children: ReactNode;
  dictionary: Dictionary;
}) {
  return (
    <TranslationsContext.Provider value={dictionary}>
      {children}
    </TranslationsContext.Provider>
  );
}

export function useTranslations(namespace?: string) {
  const dictionary = useContext(TranslationsContext);
  const params = useParams();
  const locale = params?.locale as Locale;

  if (!dictionary) {
    throw new Error('useTranslations must be used within TranslationsProvider');
  }

  const t = (key: string, variables?: Record<string, string>): string => {
    const keys = namespace ? `${namespace}.${key}`.split('.') : key.split('.');
    let value: any = dictionary;

    for (const k of keys) {
      value = value?.[k];
    }

    if (typeof value !== 'string') {
      console.warn(`Translation missing for key: ${key}`);
      return key;
    }

    // استبدال المتغيرات
    if (variables) {
      return Object.entries(variables).reduce(
        (str, [varKey, varValue]) => str.replace(`{{${varKey}}}`, varValue),
        value
      );
    }

    return value;
  };

  return { t, locale };
}

// الاستخدام في مكون العميل
function MyComponent() {
  const { t } = useTranslations('products');

  return <h1>{t('title')}</h1>;
}
تحذير: تجنب خلط أساليب الترجمة من الخادم والعميل. استخدم getDictionary() في مكونات الخادم وuseTranslations() في مكونات العميل باستمرار.

تمرين تطبيقي

المهمة: بناء صفحة منتج متعددة اللغات للتجارة الإلكترونية:

  1. دعم اللغات الإنجليزية والعربية والإسبانية
  2. تنفيذ دعم RTL مناسب للعربية
  3. إنشاء مبدل لغة في التنقل
  4. تنسيق الأسعار وفقاً للغة (USD للإنجليزية، SAR للعربية، EUR للإسبانية)
  5. تنسيق تواريخ المنتجات بتنسيق خاص باللغة
  6. إضافة علامات hreflang لتحسين محركات البحث
  7. ترجمة أسماء وأوصاف المنتجات من بيانات API

إضافي: تنفيذ نظام احتياطي للترجمة يعرض الإنجليزية إذا كانت الترجمة مفقودة للغة الحالية.

الملخص

النقاط الرئيسية حول التدويل في Next.js:

  • استخدم middleware للكشف التلقائي عن اللغة والتوجيه
  • نظم الترجمات في ملفات قاموس JSON لكل لغة
  • دعم لغات RTL مع تعديلات dir وCSS المناسبة
  • استخدم Intl API لتنسيق الأرقام والعملات والتواريخ
  • تنفيذ روابط وتنقل واعية باللغة
  • إضافة علامات hreflang لتحسين محركات البحث متعددة اللغات
  • فصل أساليب الترجمة من جانب الخادم (getDictionary) ومن جانب العميل (useTranslations)