JSON-LD في Next.js App Router: كيف نكتب أنواع البيانات المنظَّمة ونسلسلها ونختبرها — ملاحظات ميدانية
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗الخلاصة: في Next.js App Router يوضع JSON-LD في وسم script عادي من نوع application/ld+json يعرضه مكوّن خادم (Server Component)، ويُسلسل عبر JSON.stringify(data).replace(/</g, '\\u003c') حتى لا يتمكن محتوى الصفحة من إغلاق الوسم، وتُكتب أنواعه بحزمة schema-dts. في 2026 نضيف Person أو Organization وWebSite وBlogPosting وBreadcrumbList، ولم نعد نضيف FAQPage أو HowTo من أجل النتائج المنسّقة لأن Google أوقفت عرضها لمعظم المواقع في 2023.
عندما أضفنا البيانات المنظَّمة لأول مرة إلى مشاريع Next.js App Router، فعلنا ذلك بالطريقة السريعة: JSON.stringify داخل وسم script في أي مكوّن تتوفر فيه البيانات. ومع الوقت ظهرت ثلاث مشكلات: حقل محتوى يحتوي </script> يمكنه الخروج من الوسم، وتواريخ JSON-LD انحرفت عن التواريخ الظاهرة في الصفحة، وعدة أنواع schema كنا نضيفها لم تعد تُنتج أي شيء في بحث Google. هذه ملاحظاتنا من تنظيف ذلك، والإعداد الذي نعتمده الآن في Devya لكل موقع محتوى.
أهم النقاط
• JSON-LD (أي JSON for Linking Data) هو كتلة JSON تصف الصفحة باستخدام مفردات schema.org؛ لا ينفّذها المتصفح، وتوصي Google به بدلاً من Microdata وRDFa.
• لا يحتوي Metadata API في Next.js على حقل لـ JSON-LD، لذلك نعرض وسم script من نوع application/ld+json داخل مكوّن الخادم الذي يملك البيانات.
• الدالة JSON.stringify لا تهرّب المحرف <، لذلك نستبدل كل < بـ \u003c قبل الحقن، وإلا فإن وجود </script> داخل المحتوى يغلق الوسم مبكراً.
• توفّر حزمة schema-dts أنواع TypeScript لكل أنواع schema.org وقت الترجمة، لكنها لا تفرض الخصائص التي توصي بها Google؛ هذا دور اختبار يعمل على HTML المُولَّد.
• لم تعد FAQPage وHowTo تمنحان نتائج منسّقة (rich results) لمعظم المواقع، أما Person وOrganization وWebSite وBlogPosting وBreadcrumbList فما زالت تستحق الإضافة.
ما الذي يفعله JSON-LD فعلاً لموقع Next.js في 2026؟
يؤدي JSON-LD مهمتين منفصلتين: يجعل الصفحة مؤهلة لنتائج منسّقة محددة في Google، ويصرّح بوضوح من كتب الصفحة ومتى وعن أي كيان تتحدث. وقد صرّحت Google بأن البيانات المنظَّمة ليست عامل ترتيب عاماً، لذلك لا تمنح أيٌّ من المهمتين دفعة مباشرة في الترتيب.
مهمة النتائج المنسّقة تتقلّص. منذ أغسطس 2023 تعرض Google نتائج الأسئلة الشائعة (FAQ) فقط للمواقع الحكومية والصحية المعروفة وذات الموثوقية، وأُزيلت نتائج HowTo في العام نفسه، وفي 2025 أوقفت Google دفعة أخرى من ميزات البيانات المنظَّمة الأقل استخداماً. أما مهمة إزالة الالتباس فلم تتقلّص: كائن BlogPosting يحتوي datePublished وdateModified وauthor مرتبطاً بكائن Person مع روابط sameAs لا يزال أوضح إجابة يقرؤها الحاسوب عن سؤال «من قال هذا، ومتى؟».
بالنسبة لمحركات الإجابة بالذكاء الاصطناعي فالأدلة أقل. صرّح فريق Bing في Microsoft علناً بأن ترميز schema يساعد نماذجه اللغوية على فهم المحتوى، بينما لم توثّق OpenAI وPerplexity وAnthropic كيف تستخدم زواحفها JSON-LD. ما نتحكم فيه هو طريقة التسليم: كثير من زواحف الذكاء الاصطناعي تجلب HTML الخام دون تشغيل JavaScript، لذلك يجب أن يكون JSON-LD داخل HTML المُولَّد على الخادم، لا أن يُحقن بعد مرحلة hydration.
أين يجب وضع JSON-LD في Next.js App Router؟
يوضع JSON-LD داخل مكوّن الخادم الذي يملك البيانات بالفعل، وهو غالباً page.tsx، كعنصر script عادي من نوع application/ld+json. لا يحتوي Metadata API في Next.js (أي metadata وgenerateMetadata) على حقل لـ JSON-LD، وتوصي وثائق Next.js بعرض وسم script من داخل layout أو page بدلاً من ذلك.
نستخدم مكوّناً صغيراً واحداً في كل الصفحات: مكوّن خادم بلا 'use client' يستقبل Graph أو WithContext<Thing> من schema-dts، ويعرض عنصر script من نوع application/ld+json قيمة dangerouslySetInnerHTML فيه هي JSON.stringify(data).replace(/</g, '\\u003c').
تفصيلان مهمان. المكوّن لا يحتوي 'use client'، فلا يرسل أي JavaScript إلى المتصفح ويصل JSON ضمن HTML الأولي. وهو وسم script عادي وليس next/script، لأن next/script مصمَّم للتحكم في تحميل السكربتات القابلة للتنفيذ، وكتلة البيانات لا تحتاج إلى تحميل.
كيف نسلسل JSON-LD دون فتح ثغرة XSS؟
نهرّب كل محرف < إلى \u003c بعد JSON.stringify. تهرّب JSON.stringify علامات الاقتباس ومحارف التحكم لكنها تترك < و> و/ كما هي، ومحلّل HTML ينهي عنصر script عند أول </script يصادفه بغض النظر عن علامات الاقتباس في JSON.
هذا يحوّل أي نص غير موثوق إلى نقطة اختراق. إذا احتوى عنوان من نظام إدارة المحتوى أو اسم كاتب أو مقتطف تعليق على </script><script>alert(1)</script>، يغلق المتصفح كتلة JSON-LD مبكراً وينفّذ الوسم التالي كـ JavaScript. التسلسل \u003c هو تهريب صالح في JSON يُقرأ مرة أخرى كـ <، فتبقى البيانات كما هي بينما يصبح تكوين </script أو <!-- مستحيلاً. ويستخدم دليل JSON-LD في وثائق Next.js الاستبدال نفسه. خاصية dangerouslySetInnerHTML هي الطريقة الموثّقة لتعيين محتوى الوسم، واستدعاء replace هو ما يجعلها آمنة.
كيف نكتب أنواع كائنات schema.org في TypeScript؟
توفّر حزمة schema-dts، المنشورة من حساب Google على GitHub، أنواع TypeScript لكامل مفردات schema.org. النوع WithContext<T> يضيف المفتاح @context إلى عقدة واحدة، والنوع Graph يصف مستنداً يحتوي مصفوفة @graph. هذه الأنواع تعمل وقت الترجمة فقط ولا تضيف شيئاً إلى حجم الحزمة.
نبني رسم الصفحة كاملاً في وحدة واحدة ونربط العقد عبر @id، وهو معرّف على شكل رابط يسمح لعقدة بالإشارة إلى أخرى دون تكرارها. تعرّف الوحدة عقدة Person بمعرّف ينتهي بـ /#person مع اسم ورابط وروابط sameAs إلى GitHub وLinkedIn، وعقدة WebSite يشير ناشرها إلى معرّف الشخص. وتبني الدالة postGraph(post) عقدة BlogPosting معرّفها رابط المقال متبوعاً بـ #article، مع headline وdescription وimage وتاريخي datePublished وdateModified عبر toISOString()، وauthor وpublisher كمراجع { '@id': PERSON_ID }، ثم تعيد مستنداً قيمة @context فيه هي schema.org وتحتوي مصفوفة @graph فيه عقد الشخص والموقع والمقال.
نُصدر @graph واحداً لكل صفحة يكرّر عقدتي Person وWebSite على مستوى الموقع، بدلاً من كتلة عامة في الـ layout الجذري وكتلة منفصلة للصفحة. لا نريد الاعتماد على افتراض أن الجهة المستهلكة ستدمج مراجع @id عبر وسوم script منفصلة.
تكتشف schema-dts الأخطاء الإملائية مثل datePublised والقيم ذات الشكل الخاطئ، لكنها لا تفرض ما توصي به Google: كل الخصائص تقريباً اختيارية في مفردات schema.org، ووثائق Article لدى Google لا تذكر أي خاصية إلزامية، بل خصائص موصى بها مثل headline وimage وdatePublished وdateModified وauthor. الأنواع تحافظ على سلامة شكل JSON، والاختبار يحافظ على اكتماله.
أي أنواع schema.org لا تزال تستحق الإضافة في 2026؟
أنواع الكيانات والمقالات تستحق الإضافة في كل موقع محتوى، أما FAQPage وHowTo فلا تستحقان الإضافة من أجل النتائج المنسّقة. هذا هو الجدول الذي يعتمده فريقنا:
• Person أو Organization — في كل صفحة داخل @graph. تساعد Google على فهم الكيان (الكاتب، الشعار، حسابات sameAs) دون مقتطف خاص بها. قرارنا: نضيفها.
• WebSite — في كل صفحة داخل @graph. تُستخدم لعرض اسم الموقع في النتائج، وتُقرأ من ترميز الصفحة الرئيسية. قرارنا: نضيفها.
• BlogPosting أو Article — في صفحات المقالات. تحسّن فهم العنوان والصورة والتاريخ دون بطاقة مخصصة. قرارنا: نضيفها.
• BreadcrumbList — في الصفحات المتداخلة. تعرض مسار التنقل في نتائج سطح المكتب، وقد أوقفته Google على الجوال في يناير 2025. قرارنا: نضيفها في المواقع العميقة.
• FAQPage — فقط عندما تكون الأسئلة ظاهرة في الصفحة. النتائج المنسّقة مقتصرة على المواقع الحكومية والصحية المعروفة منذ أغسطس 2023. قرارنا: اختيارية ولا تُنتج مقتطفاً.
• HowTo — لا نضيفها في أي مكان. أُزيلت نتائجها المنسّقة في 2023. قرارنا: نتخطاها.
• Product مع Offer — في صفحات المتاجر. تُنتج مقتطفات المنتجات وقوائم التجار. قرارنا: نضيفها إذا كان الموقع يبيع منتجات.
نُبقي قسم الأسئلة الشائعة الظاهر في مقالات مثل هذا المقال حتى دون ترميز FAQPage، لأن نصوص السؤال والجواب سهلة الاقتباس لمحركات الإجابة، وهذه القيمة لم تعتمد يوماً على بطاقة Google.
كيف نُبقي JSON-LD متطابقاً مع ما تعرضه الصفحة؟
نولّد JSON-LD والصفحة الظاهرة من كائن البيانات نفسه في الطلب نفسه. تشترط إرشادات البيانات المنظَّمة لدى Google أن يصف الترميز محتوى ظاهراً في الصفحة، والترميز الذي يضلّل بشأن الصفحة قد يؤدي إلى إجراء يدوي (manual action).
في App Router تجعل الدالة cache() من React هذا الكائن المشترك رخيصاً: تستدعي generateMetadata ومكوّن الصفحة الدالة المخزّنة نفسها، فيأتي وسم title وعنوان h1 والخاصية headline من عملية جلب واحدة. تغلّف الصفحة getPostBySlug بـ cache()، وتنتظر params في generateMetadata وفي المكوّن الافتراضي، وتستدعي notFound() للروابط غير المعروفة، وتعرض <JsonLd data={postGraph(post)} /> فوق h1 مع عنصر time قيمة dateTime فيه هي publishedAt.toISOString().
التواريخ هي أول مكان ظهر فيه عدم التطابق لدينا. نخزّن publishedAt وupdatedAt كقيم Date ونسلسلها بـ toISOString() التي تتضمن دائماً محدّد المنطقة الزمنية، ونحدّث dateModified فقط عندما يتغيّر المحتوى تغيّراً حقيقياً، لا مع كل عملية نشر.
أخيراً، يلتقط اختبار يعمل على HTML المُولَّد ما لا تلتقطه الأنواع: غياب BlogPosting، أو تاريخ بلا منطقة زمنية، أو عنوان لم يعد مطابقاً لـ h1. يعمل الاختبار على مشغّل الاختبارات المدمج في Node ضد next start: يجلب صفحة مقال، ويستخرج كل وسوم application/ld+json بتعبير نمطي، ويحلّل كل واحد منها ويفكّك مصفوفات @graph، ثم يتحقق من وجود BlogPosting واحد فقط، ومن أن datePublished بصيغة ISO 8601 تنتهي بـ Z أو بإزاحة زمنية، ومن أن headline يساوي نص h1 في الصفحة.
ما زلنا نشغّل أداة Rich Results Test من Google ومدقّق Schema Markup Validator على validator.schema.org يدوياً عند إضافة نوع جديد، أما اختبار CI فهو ما يمنع التراجعات بعد ذلك.
الأسئلة الشائعة
**س:** هل يحسّن JSON-LD ترتيب الموقع في Google؟
**ج:** ليس بشكل مباشر. صرّحت Google بأن البيانات المنظَّمة ليست عامل ترتيب عاماً؛ فهي تجعل الصفحات مؤهلة للنتائج المنسّقة وتساعد Google على فهم الكيانات والتواريخ والتأليف.
**س:** هل نستخدم next/script لإضافة JSON-LD في Next.js؟
**ج:** لا. يدير next/script استراتيجيات تحميل السكربتات القابلة للتنفيذ، أما وسم script عادي من نوع application/ld+json يعرضه مكوّن خادم فيضع البيانات في HTML الأولي، وهذا ما توصي به وثائق Next.js.
**س:** هل يمكن وضع JSON-LD في body بدلاً من head؟
**ج:** نعم. تقرأ Google JSON-LD في أي مكان من مستند HTML، ويعرض React 19 الوسم في المكان الذي تضعه فيه داخل شجرة المكوّنات.
**س:** هل لا يزال ترميز FAQPage يستحق الإضافة في 2026؟
**ج:** ليس من أجل النتائج المنسّقة في معظم المواقع، لأن Google قصرت نتائج FAQ على المواقع الحكومية والصحية المعروفة منذ أغسطس 2023. لكن قسم أسئلة شائعة ظاهر لا يزال مفيداً للقرّاء ولمحركات الإجابة.
**س:** هل يقرأ ChatGPT وPerplexity بيانات JSON-LD؟
**ج:** لم توثّق OpenAI ولا Perplexity كيف تستخدم زواحفهما JSON-LD. عرضه على الخادم لا يكلّف شيئاً ويُبقيه مرئياً للزواحف التي لا تنفّذ JavaScript.
قراءات إضافية
هندسة الواجهات
الوضع الداكن دون وميض في Next.js 16: كيف نستخدم سكربت سمة حاجبًا وcolor-scheme وزر تبديل آمنًا مع الـ Hydration
وميض الوضع الداكن عند إعادة تحميل الصفحة مشكلة توقيت: الخادم لا يستطيع قراءة localStorage. نشرح سكربت السمة الحاجب الذي نستخدمه في Next.js 16، ولماذا لا نضع السمة في cookie حفاظًا على الرسم الثابت، وكيف نبني زر تبديل يمر بالـ hydration دون أي عدم تطابق.
هندسة الواجهات
صور Open Graph الديناميكية في Next.js 16: كيف نبني opengraph-image.tsx ونتحقق من إصدار Satori الذي تضمّنه next/og ونحافظ على صحة معاينات الروابط
واجهت بطاقات المشاركة المولّدة لدينا ثلاث مفاجآت: خط WOFF2 لم يستطع المحرّك قراءته، وملف README لـ Satori يصف محرّكًا أحدث من المضمّن في Next.js، وصفحة ظلّت تعرض صورة الـ layout. هذه ملاحظاتنا الميدانية حول opengraph-image.tsx في Next.js 16 وأولوية البيانات الوصفية وفحص og:image قبل مشاركة أي رابط.