neptay
كل الرؤى

الهندسة البرمجية

بناء موقع ثنائي اللغة على Next.js App Router: التدويل وhreflang والبيانات المنظَّمة كما ينبغي

شرح عملي لطريقة إطلاقنا مواقع ثنائية اللغة (الإنجليزية والتركية) على Next.js App Router: توجيه المسارات حسب اللغة، وبدائل hreflang، وJSON-LD لكل لغة، والحيل الصغيرة في البرمجية الوسيطة التي تُبقي اللغتين على قدم المساواة.

في هذا المقال

معظم المواقع ثنائية اللغة هي في الحقيقة مواقع أحادية اللغة أُلصقت بها طبقة ترجمة. النسخة الإنجليزية هي التي تُطلَق أولًا، وهي التي تعمل نماذجها، وهي التي تتعرّف إليها محركات البحث أولًا. أما النسخة المترجمة فمجرد ملف JSON سقط من ذاكرة ترجمة، وقائمة منسدلة في رأس الصفحة لم يختبرها أحد.

لم يكن هذا ما أردنا بناءه. بدأ موقع neptay.com بالإنجليزية والتركية، ويعمل اليوم بعشر لغات من طبقة المحتوى نفسها، مع توجيه مسارات وبيانات وصفية وخرائط موقع وhreflang وبيانات منظَّمة تراعي اللغة كلها. النسخة التركية ليست إضافة لاحقة، بل هي الموقع نفسه بصوت آخر. هذا المقال جولة هندسية: كيف أعددنا ذلك على Next.js App Router، وما الذي نضعه في البرمجية الوسيطة، وكيف يجب أن يبدو hreflang كي يثق به Google، والقرارات البنيوية الصغيرة التي تؤتي ثمارها لاحقًا.

إن كنتم تُطلقون موقعًا صغيرًا ثنائي اللغة، كصفحة استوديو أو قائمة طعام لمطعم أو صفحة هبوط إقليمية، فيمكنكم نسخ هذا النمط كما هو تقريبًا. وإن كنتم تُطلقون متجرًا إلكترونيًا بخمسين لغة، فهذا هو الأساس؛ وكل ما عليكم هو استبدال القاموس بتغذية من نظام إدارة محتوى.

شكل مساحة العناوين

هناك في الأساس ثلاثة خيارات لتنظيم عناوين URL في موقع Next.js ثنائي اللغة:

  1. نطاق فرعي لكل لغة (en.example.com وtr.example.com). فصل قوي، لكن إدارة شهادات TLS والتحليلات فيه مكلفة، ويبدو مجزّأً بصريًا في موقع صغير.
  2. نطاق دولة من المستوى الأعلى لكل لغة (example.com وexample.com.tr). ممتاز للاستهداف الجغرافي، لكنه لا يُبرَّر إلا إذا كنتم تديرون فعلًا نشاطًا تجاريًا خاصًا بتركيا، وإلا فهو مكلف.
  3. بادئة لغة في كل مسار (example.com/en/ وexample.com/tr/). نطاق واحد، ونشر واحد، وخاصية تحليلات واحدة. هذا ما نستخدمه، وما ينبغي أن تستخدمه معظم المواقع الصغيرة ثنائية اللغة.

يتعامل Next.js App Router مع بادئات اللغة بسلاسة لأن المسارات فيه قائمة على نظام الملفات. يعيش الموقع كله تحت app/[locale]/، وتتلقى كل صفحة اللغةَ كمعامل مسار. لا صفحة رئيسية تُعامَل معاملة خاصة، ولا فصل خفي بين مسارات «مترجمة» وأخرى «غير مترجمة»؛ فاللغة مجرد مقطع آخر في المسار.

هيكل المجلدات الذي نستخدمه

مثال برمجي
app/
  [locale]/
    layout.tsx       ← root layout: <html lang>, nav, footer, JSON-LD, hreflang
    page.tsx         ← home
    services/page.tsx
    work/
      page.tsx       ← work index
      [slug]/page.tsx← case study
    studio/page.tsx
    contact/page.tsx
    insights/
      page.tsx
      [slug]/page.tsx
    [...rest]/page.tsx ← catch-all → notFound()
    not-found.tsx    ← localized 404
  sitemap.ts
  robots.ts

لاحظوا ما هو غائب: لا يوجد app/layout.tsx. تخطيط اللغة هو نفسه التخطيط الجذري؛ فهو يملك عنصر <html> مباشرةً، لذا تأتي <html lang="…"> من معامل المسار مباشرةً. لا حيل عبر الترويسات، ولا ترقيع من جهة العميل، والأهم من ذلك: لا استدعاء لـ headers()، الذي كان سيُخرج كل مسار بصمت من التوليد الثابت. أما الأقسام التي تعيش خارج نظام اللغات (عروضنا التجريبية الحية) فلها تخطيطها الجذري الخاص؛ إذ يدعم Next.js عدة تخطيطات جذرية، وحدود اللغة هي المكان المناسب تمامًا لاستخدامها.

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

حين يصل أحدهم إلى النطاق المجرد، عليكم أن تقرروا: إلى أين يذهب؟ لديكم ثلاث إشارات: عنوان URL (إن كتب /tr/ صراحةً)، وترويسة Accept-Language (ما يريده المتصفح)، وملف تعريف ارتباط (ما اختاره في المرة السابقة). القاعدة التي نتبعها هي القاعدة المملة التي تنجح:

  1. إن كان عنوان URL يحمل بادئة لغة أصلًا، نحترمها. ونضبط ملف تعريف ارتباط لنتذكّر الاختيار.
  2. وإلا، إن وُجد ملف تعريف ارتباط من زيارة سابقة، نعيد التوجيه إلى تلك اللغة.
  3. وإلا، إن كانت ترويسة Accept-Language تطابق لغة ندعمها، نعيد التوجيه إليها.
  4. وإلا، نعود إلى اللغة الافتراضية (الإنجليزية).

نفعل ذلك في middleware.ts لا في أحد التخطيطات؛ فهو أسرع، ويعمل على الحافة (edge)، ويتجنّب أخطاء عدم التطابق في مرحلة hydration. هذه نسخة مبسّطة من البرمجية الوسيطة التي تعمل على neptay.com:

middleware.tsTypeScript
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { defaultLocale, isLocale, locales } from "@/lib/i18n";

const LOCALE_COOKIE = "neptay.locale";

function pickLocaleFromAcceptLanguage(header: string | null): "en" | "tr" {
  if (!header) return defaultLocale;
  const lower = header.toLowerCase();
  if (/(^|[,;\s])tr(-|;|,|$)/.test(lower)) return "tr";
  return defaultLocale;
}

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

  // Live demos live outside the locale system — they handle their own language.
  if (pathname === "/demo" || pathname.startsWith("/demo/")) {
    return NextResponse.next();
  }

  const matchedLocale = locales.find(
    (l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`)
  );
  if (matchedLocale) {
    const res = NextResponse.next();
    // Remember the locale so the next bare-domain visit lands on it.
    if (req.cookies.get(LOCALE_COOKIE)?.value !== matchedLocale) {
      res.cookies.set(LOCALE_COOKIE, matchedLocale, {
        path: "/",
        maxAge: 60 * 60 * 24 * 365,
        sameSite: "lax",
      });
    }
    return res;
  }

  // Honour the user's manual choice if it was already remembered.
  const cookieLocale = req.cookies.get(LOCALE_COOKIE)?.value;
  const remembered = cookieLocale && isLocale(cookieLocale) ? cookieLocale : null;
  const detected = remembered ?? pickLocaleFromAcceptLanguage(req.headers.get("accept-language"));

  const url = req.nextUrl.clone();
  url.pathname = `/${detected}${pathname === "/" ? "" : pathname}`;
  return NextResponse.redirect(url);
}

export const config = {
  // Skip Next internals + static assets so they aren't redirected.
  matcher: [
    "/((?!_next/|api/|favicon\\.|robots\\.txt|sitemap\\.xml|.*\\.(?:png|svg|jpg|jpeg|gif|webp|ico|txt|xml)).*)",
  ],
};

تفصيلتان هنا تستحقان عناءهما. فحص Accept-Language تحليلٌ حقيقي لقائمة الترويسة المفصولة بفواصل، لا مجرد startsWith؛ فالمتصفح الذي يرسل «en-GB,tr;q=0.8» لا ينبغي أن يُعامَل على أنه يطلب التركية لمجرد ظهور «tr» في مكان ما. ويُكتب ملف تعريف الارتباط في كل زيارة تحمل بادئة لغة، لا عند التبديل اليدوي فقط، فتصل الزيارة التالية إلى النطاق المجرد إلى آخر لغة قرأ بها الزائر. ومن دون ذلك يبدو تبديل اللغة معطّلًا: فالزائر العائد الذي اختار التركية صراحةً يُعاد إلى تخمين ترويسة Accept-Language مجددًا.

البيانات الوصفية لكل لغة ولكل مسار

يمكن لكل صفحة في App Router أن تُصدِّر generateMetadata. وفي موقع ثنائي اللغة، يجب أن تحدث ثلاثة أمور في هذه الدالة:

  1. ضبط العنوان والوصف باللغة الحالية.
  2. ضبط عنوان URL أساسي (canonical) يشير إلى هذا المسار بالضبط (بلا مشكلات الشرطة المائلة في النهاية، وبلا غرائب البروتوكول).
  3. ضبط بدائل hreflang لكل لغة تتوفر بها هذه الصفحة، مع إضافة قيمة x-default أيضًا.

يجعل Next.js ذلك أمرًا بسيطًا بفضل الكائن alternates.languages. هذا هو الشكل لكل مسار:

مثال برمجيTypeScript
export async function generateMetadata({ params }: { params: { locale: string } }): Promise<Metadata> {
  if (!isLocale(params.locale)) return {};
  const seo = WORK_SEO[params.locale];
  return {
    title: seo.title,
    description: seo.description,
    alternates: {
      canonical: `/${params.locale}/work`,
      languages: {
        en: "/en/work",
        tr: "/tr/work",
        "x-default": "/en/work",
      },
    },
    openGraph: { title: seo.title, description: seo.description, type: "website" },
  };
}

أمران يخطئ فيهما الناس بشأن hreflang:

  • يجب أن يشير كل بديل إلى الآخر. إذا أدرجت /en/work الصفحةَ /tr/work كبديل، فيجب أن تُدرج /tr/work الصفحةَ /en/work أيضًا. غياب الإحالات المتبادلة يجعل Google يتجاهل مجموعة hreflang بأكملها.
  • القيمة x-default ليست لغة، بل هي عنوان URL للمستخدمين الذين لا تطابق لغتهم أيًّا من لغاتكم. نوجّهها إلى /en/work لأن الإنجليزية هي البديل الأوسع لدينا، لا لأن الإنجليزية أهم.

إن كانت لديكم صفحات كثيرة، فاكتبوا دالة مساعدة صغيرة تولّد كائن alternates لأي مسار. نحن نكتبه مباشرةً في كل صفحة لأن صفحاتنا قليلة، والنسخة الصريحة أسهل في تتبّع الأخطاء حين تحدّقون في Search Console في الحادية عشرة ليلًا.

خرائط الموقع، وhreflang داخل خريطة الموقع

إدراج hreflang لكل مسار في البيانات الوصفية نصف المهمة فقط. فـGoogle يقرأ hreflang من خريطة الموقع أيضًا، وهي الإشارة الأكثر موثوقية لأنها تغطي الموقع كله دفعة واحدة. لدى App Router اصطلاح مدمج هو sitemap.ts؛ وهكذا تبدو خريطتنا:

app/sitemap.tsTypeScript
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { locales } from "@/lib/i18n";

const SITE = "https://neptay.com";
const PATHS = ["", "services", "work", "studio", "contact", "insights"] as const;

export default function sitemap(): MetadataRoute.Sitemap {
  const lastModified = new Date();
  return locales.flatMap((locale) =>
    PATHS.map((p) => ({
      url: `${SITE}/${locale}${p ? `/${p}` : ""}`,
      lastModified,
      changeFrequency: "monthly" as const,
      priority: p === "" ? 1 : 0.7,
      alternates: {
        languages: Object.fromEntries(
          locales.map((l) => [l, `${SITE}/${l}${p ? `/${p}` : ""}`])
        ),
      },
    }))
  );
}

لاحظوا أن خريطة الموقع تُصدر إدخالًا واحدًا لكل تركيبة (لغة، مسار)، وأن كل إدخال يسرد جميع البدائل اللغوية. في هذا تكرار، وهو مقصود. فمحركات البحث تفضّل الزحف إلى أزواج صريحة على تخمينها من أنماط العناوين.

البيانات المنظَّمة: بنية أساسية واحدة وامتدادات لكل مسار

يُعدّ JSON-LD وفق Schema.org الركيزة الثانية في فهم Google لموقع متعدد اللغات. ونحن نقسّمه إلى طبقتين:

  1. بنية أساسية (Organization + WebSite) يحقنها التخطيط الجذري مرة واحدة. تُعلن لمن يعود الموقع، وأين يوجد الشعار، وبأي لغات يُنشر الموقع.
  2. مخططات خاصة بكل مسار (CreativeWork وBreadcrumbList وArticle وFAQPage) تحقنها كل صفحة. وهي كتل JSON-LD صغيرة تصف ماهية هذه الصفحة تحديدًا.

في كلتيهما تُضبط inLanguage على اللغة الحالية. لا تتجاوزوا هذه الخطوة؛ فبدون inLanguage يضطر Google إلى استنتاج اللغة من نص الصفحة، وهذا بطيء وغير دقيق في الصفحات القصيرة.

lib/structured-data.tsTypeScript
// lib/structured-data.ts
export const organizationLd = {
  "@type": "Organization",
  "@id": `${SITE_URL}/#organization`,
  name: "Neptay",
  url: SITE_URL,
  logo: { "@type": "ImageObject", url: `${SITE_URL}/neptay-logo.png` },
  knowsLanguage: ["en", "tr"],
  areaServed: "Worldwide",
};

export function articleLd(input: ArticleInput) {
  return {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: input.headline,
    inLanguage: input.locale,            // ← critical for bilingual sites
    datePublished: input.datePublished,
    author: { "@id": `${SITE_URL}/#organization` },
    publisher: { "@id": `${SITE_URL}/#organization` },
    // …
  };
}

نمط القاموس

تعيش نصوص الترجمة في قاموس واحد محدد الأنواع، يُصدَّر كخريطة ثابتة (const) مفاتيحها اللغات. هذا ليس رائجًا؛ فالنصيحة الحديثة هي استخدام إطار عمل مثل next-intl أو react-intl مع صيغة رسائل ICU. لكن في موقع صغير، يكون القاموس محدد الأنواع أسرع وأصغر، ويكشف الترجمات الناقصة عند التصريف البرمجي:

مثال برمجيTypeScript
export type Locale = "en" | "tr";
export const locales: Locale[] = ["en", "tr"];

export interface Dictionary {
  locale: Locale;
  nav: { services: string; work: string; studio: string; contact: string };
  // …
}

export const dictionaries: Record<Locale, Dictionary> = {
  en: { /* … */ },
  tr: { /* … */ },
};

إن نقصت الترجمة التركية لنص ما، يعترض TypeScript أثناء البناء. وإن أُضيف مفتاح في الإنجليزية دون التركية، يفشل البناء. لهذا قيمة كبيرة حين تكون لديكم لغتان، فهو يعني أن الطريق الوحيد إلى الإطلاق هو إبقاؤهما متطابقتين خطوة بخطوة.

ينهار هذا النمط عند نحو عشرين لغة، أو حين يحتاج غير المهندسين إلى تعديل النصوص. عندها انقلوا القاموس إلى نظام إدارة محتوى، وتقبّلوا أن الترجمات ستتأخر عن الأصل. أما للغتين وموقع استوديو، فإن TypeScript البسيط يتفوّق.

العرض الثابت والتخزين على الحافة، وأثرهما في محركات البحث

كل ما سبق محايد من ناحية الأداء في تحميل صفحة واحدة. أهميته تظهر على نطاق واسع. حين تبقى كل صفحة معروضة عرضًا ثابتًا (بلا استعلامات قاعدة بيانات وقت التشغيل، وبلا استدعاءات ترجمة مع كل طلب)، تصبح كل لغة من كل صفحة مستند HTML قابلًا للتخزين المؤقت ومخزّنًا على الحافة. وزمن وصول البايت الأول هو ما تقوله شبكة CDN لديكم، عادةً من 30 إلى 80 ملّي ثانية في أي مكان على الكوكب.

ويهمّ ذلك لتحسين محركات البحث لسببين: تمنح مؤشرات Core Web Vitals وزنًا كبيرًا لـ TTFB وLCP، ولزاحف Google ميزانية محددة. الصفحة الثابتة المخزّنة على الحافة تكلّف الزاحف رحلة ذهاب وإياب واحدة رخيصة؛ أما الصفحة المعروضة على الخادم فقد تكلّفه عشرة أضعاف ذلك. ميزانية الزحف ليست مشكلة لموقع استوديو من خمس صفحات، لكنها تصبح مشكلة سريعًا لمدونة متعددة اللغات فيها مئتا مقال.

ما الذي ما زلنا سنحسّنه

قائمة صادقة بما يستحق العمل عليه في موقع App Router ثنائي اللغة بعد إتقان الأساسيات:

  • صور OpenGraph لكل لغة. صورة OG حاليًا واحدة في اللغتين؛ والأمثل أن يُترجَم العنوان المكتوب على الصورة أيضًا.
  • اكتشاف انحراف الترجمة تلقائيًا. خطوة في CI تقارن مفاتيح القاموسين الإنجليزي والتركي وتفشل عند عدم التطابق. سهلة وقيّمة.
  • مكوّن لتبديل اللغة يُبقي المستخدم في الصفحة نفسها إن وُجدت ترجمة، ويتراجع بلطف (مع ملاحظة) إن لم توجد.

يسعدنا تواصلكم عبر hello@neptay.com إن كنتم تخططون لموقع ثنائي اللغة وتريدون عينًا ثانية على بنيته، أو تريدون أن نبنيه لكم.

Neptay Media & Technology Services

لنتحدث

هل تخطط لمشروع مشابه؟

الأساليب التي تتناولها هذه المقالات هي نفسها التي نعتمدها في مشاريع العملاء — البرمجيات، والأتمتة بالذكاء الاصطناعي، والمحتوى، ووسائل التواصل الاجتماعي، والإنتاج المباشر. أخبرنا بما تفكر فيه؛ نرد خلال 24 ساعة.