لنفرض أن لديك موقعاً لشركتك يعمل على WordPress، ثم قرر الفريق بناء تطبيق للجوال يعرض الأخبار نفسها، ثم طلبت الإدارة شاشة في الاستقبال تعرض آخر الإعلانات، فستجد أن المحتوى محبوس داخل قوالب الموقع وأن كل واجهة جديدة تحتاج إلى نسخ المحتوى أو إلى حيل لاستخراجه من صفحات HTML. والحل الذي انتشر لهذه المشكلة هو نظام إدارة المحتوى بلا واجهة Headless CMS، أي نظام يحتفظ بلوحة التحرير وقاعدة البيانات ويتخلى عن عرض الصفحات، ثم يقدم المحتوى عبر API لأي واجهة تطلبه.
وهذا المقال هو أول مقالات مرحلة منصات للمطورين في خارطة الطريق، وسوف نناقش فيه ما يلي:
- ما الفرق بين ال CMS التقليدي وال Headless، ومتى تحتاج إليه ومتى لا تحتاج.
- نموذج المحتوى Content Model وال API الذي يتولد منه بصيغتي REST وGraphQL.
- نوعا الصلاحيات: من يكتب في لوحة التحكم، ومن يقرأ عبر ال API، ولماذا تتسرب المسودات.
- أشهر أداتين تستضيفهما بنفسك: Strapi وDirectus، وترخيص كل منهما كما هو اليوم بعد تغيير Directus لترخيصه في الإصدار 12.
- تجربة عملية على الأداتين بالنموذج نفسه، وفيها تسريب حقيقي للمسودات في Strapi وإصلاحه، وحدود الطبقة المجانية في Directus كما ظهرت لنا.
ما هو نظام إدارة المحتوى بلا واجهة Headless CMS؟
نظام إدارة المحتوى Content Management System، واختصاراً CMS، هو برنامج يسمح لشخص غير مبرمج بأن يكتب المحتوى وينظمه وينشره. وفي الشكل التقليدي Coupled CMS مثل WordPress وGhost وDrupal يكون كل شيء في تطبيق واحد: لوحة التحكم التي يكتب فيها المحرر، وقاعدة البيانات، والقالب Theme الذي يحول المحتوى إلى صفحات HTML يراها الزائر. وكلمة Head في الاسم تعني هذا الجزء الأخير، أي طبقة العرض، فال Headless CMS هو CMS حذفت منه طبقة العرض، وبقي له شيئان: لوحة تحرير لفريق المحتوى، و API يعيد المحتوى بصيغة JSON لمن يطلبه. والصورة التالية تبين الفرق بين الشكلين:

وفي الصورة أعلاه لاحظ أن الموقع في الشكل الثاني أصبح تطبيقاً منفصلاً يبنيه فريق الواجهة بإطار مثل Next.js أو Astro أو Nuxt، ويطلب المحتوى من ال API عند البناء أو عند كل طلب، وأن تطبيق الجوال والشاشة يقرآن من ال API نفسه دون أي نسخ. ويوجد بين الشكلين شكل ثالث يسمى المنفصل Decoupled، وهو CMS تقليدي يحتفظ بقوالبه ويقدم API إلى جانبها، مثل REST API في WordPress وContent API في Ghost، وهو حل جيد إذا كان الموقع هو الواجهة الأساسية والتطبيق إضافة صغيرة.
متى تحتاج إليه، ومتى لا تحتاج؟
وقد يتساءل البعض: إذا كان ال Headless يخدم كل الواجهات، فلماذا لا نستخدمه دائماً؟ والإجابة أنه ينقل جزءاً كبيراً من العمل إليك، فالقالب الذي كان جاهزاً في WordPress أصبح تطبيقاً تكتبه وتختبره وتستضيفه، والمعاينة Preview التي كانت زراً واحداً أصبحت ميزة تبنيها بين تطبيقين، والروابط وخرائط الموقع Sitemap وبيانات محركات البحث SEO أصبحت مسؤولية فريق الواجهة. لذلك قاعدة الاختيار كما يلي:
- اختر Headless عندما يكون للمحتوى أكثر من واجهة (موقع وتطبيق جوال وشاشات أو خدمات أخرى)، أو عندما يكون عندك فريق واجهة يعمل بإطار حديث ويريد التحكم الكامل في العرض، أو عندما يكون المحتوى جزءاً من تطبيق وليس موقعاً، مثل كتالوج منتجات أو أسئلة شائعة داخل نظام.
- لا تختره لمدونة أو موقع تعريفي يديره شخص واحد، فهنا يكفيك WordPress أو Ghost، والسبب أنك سوف تدير تطبيقين بدل تطبيق واحد دون فائدة تذكر.
نموذج المحتوى Content Model
أول ما تفعله في أي Headless CMS هو تعريف نموذج المحتوى Content Model، أي أنواع المحتوى Content Types التي يكتبها فريقك، والحقول Fields التي في كل نوع، والعلاقات Relations بين الأنواع. فالمقال مثلاً له عنوان Title نصي إجباري، ومعرف في الرابط Slug لا يتكرر، ونص Body منسق، وصورة غلاف Cover، وعلاقة مع التصنيف Category، وحالة مسودة أو منشور Draft and Published، وربما لغة Locale إذا كان الموقع بأكثر من لغة. والأداة تحول هذا التعريف إلى جداول في قاعدة البيانات، وتولد منه API كاملاً دون أن تكتب سطراً، كما تبين الصورة التالية:

وفي الصورة أعلاه لاحظ التالي:
- أنواع المجموعات Collection Types مثل المقالات والتصنيفات لها عدد كبير من العناصر، وفي المقابل يوجد النوع المفرد Single Type لمحتوى له نسخة واحدة فقط، مثل إعدادات الصفحة الرئيسية أو صفحة من نحن.
- المكونات Components مجموعة حقول تتكرر في أكثر من نوع، مثل بيانات SEO (العنوان والوصف والصورة)، فتعرفها مرة واحدة وتضيفها حيث تحتاج.
- المسودة والنشر Draft and Publish يجعل لكل عنصر نسختين، واحدة يعمل عليها المحرر وأخرى يراها الزوار، وهذا مفيد جداً وخطير في الوقت نفسه كما سوف نرى في التجربة.
- التصميم الجيد للنموذج يشبه تصميم قاعدة البيانات، فلا تكرر البيانات بين الأنواع ولا تضع في حقل نصي ما يجب أن يكون علاقة، وقد شرحنا المبادئ في تصميم قواعد البيانات.
ال API الذي تحصل عليه: REST وGraphQL
من النموذج تولد الأداة مسارات REST لكل نوع: قائمة العناصر وعنصر واحد والإنشاء والتعديل والحذف، مع معاملات للتصفية Filtering والترتيب Sorting والتقسيم إلى صفحات Pagination، ومعامل لجلب العلاقات مع العنصر (يسمى populate في Strapi، وتكتب العلاقة داخل fields في Directus). وتولد أيضاً مخطط GraphQL يسمح للواجهة بأن تطلب الحقول التي تحتاجها فقط في طلب واحد. وقد شرحنا الفرق بين الأسلوبين ومتى تختار كلاً منهما في تصميم واجهات API، والمهم هنا ثلاث نقاط خاصة بال CMS:
- الواجهة لا تحتاج إلى كل الحقول: اطلب الحقول التي تعرضها فقط، والسبب أن الرد الكامل مع العلاقات قد يكون أكبر من الصفحة نفسها، وقد يحمل حقولاً لم تقصد نشرها.
- GraphQL يحتاج إلى حدود: استعلام متداخل بعمق كبير أو يطلب آلاف العناصر يستطيع أن يشغل السيرفر، وهذا ما نبهنا إليه Strapi نفسه في السجل عند التشغيل كما سوف ترى في التجربة.
- الموقع الثابت Static Site يحتاج إلى إعادة بناء: إذا كانت الواجهة تبنى مرة واحدة ثم ترفع إلى شبكة CDN، فالمحتوى الجديد لا يظهر حتى يعاد البناء، والحل المعتاد هو Webhook يرسله ال CMS عند النشر إلى خط CI ليبني الموقع من جديد.
الصلاحيات: لوحة التحكم شيء، وال API شيء آخر
أكثر ما يخلط فيه المبتدئ في ال Headless CMS هو الصلاحيات، والسبب أن فيه نظامين منفصلين تماماً كما تبين الصورة التالية:

- صلاحيات لوحة التحكم Admin RBAC: تحدد من يغير نموذج المحتوى، ومن ينشر، ومن يكتب مسودات فقط، وهي نظام صلاحيات حسب الدور Role-Based Access Control الذي شرحناه في الصلاحيات المركزية.
- صلاحيات ال API: تحدد ما يراه من يطلب المحتوى، وفيها ثلاث فئات: الجمهور Public دون أي توكن، وهو كل زائر لموقعك وكل من يعرف رابط ال API، والتوكن الثابت API Token لسيرفر البناء أو لخدمة أخرى، ومستخدمو تطبيقك إذا كان ال CMS يدير حساباتهم عبر JWT كما شرحنا في الجلسات و JWT.
Strapi: النموذج ملفات في مشروعك
Strapi هو أشهر Headless CMS مفتوح المصدر مكتوب ب Node.js، وتنشئ به مشروعاً كما تنشئ أي مشروع Node، ثم تعرف النموذج إما من الواجهة الرسومية Content-Type Builder أثناء التطوير أو بكتابة ملفات schema.json مباشرة، وفي الحالتين يصبح النموذج ملفات داخل المشروع تحفظ في Git وتراجع في طلبات الدمج وتنشر مع الكود. ولهذا السبب يمنع Strapi تعديل النموذج من لوحة التحكم في الإنتاج، فالنموذج يتغير في بيئة التطوير ثم ينتقل بالنشر، وعند التشغيل ينشئ Strapi الجداول والأعمدة التي تنقص في قاعدة البيانات. ويدعم SQLite وPostgreSQL وMySQL وMariaDB.
أما الترخيص فالملف LICENSE في المستودع ينص على أن كل الكود خارج المجلدات المسماة ee/ بترخيص MIT، وأن ما في مجلدات ee/ هو النسخة التجارية Enterprise Edition ويخضع لاتفاقية Strapi التجارية. وعملياً تحصل في النسخة المجانية (تسمى في لوحة التحكم Community) على النموذج والمسودات والنشر وتعدد اللغات i18n ومكتبة الوسائط وال API Tokens والصلاحيات RBAC مع أدوار مخصصة، بينما تحتاج بحسب التوثيق الرسمي إلى خطة Growth لسجل تغييرات المحتوى Content History والإصدارات Releases، وإلى خطة Enterprise لسجل التدقيق Audit Logs ومسارات المراجعة Review Workflows، والدخول الموحد SSO متاح في Enterprise أو كإضافة مدفوعة على Growth.
ويجمع Strapi بيانات استخدام مجهولة Telemetry كما في صفحة بيانات الاستخدام، ومنها معرف للمشروع ومعرف للجهاز ونظام التشغيل، وتوقفها بالمتغير STRAPI_TELEMETRY_DISABLED=true أو بالأمر npm run strapi telemetry:disable.
نقاط القوة:
- ترخيص MIT للنسخة المجانية، وهي تكفي أغلب المواقع والتطبيقات دون حدود على عدد المستخدمين أو أنواع المحتوى.
- النموذج كود في Git، فتراجعه وتنقله بين البيئات مع باقي المشروع دون أدوات إضافية.
- مشروع Node عادي تضيف إليه منطقك الخاص: Controllers وServices وMiddlewares وإضافات Plugins.
نقاط الضعف:
- لا توجد Docker Image رسمية جاهزة، فأنت تبني مشروعك ثم تصنع له Docker Image بنفسك، وكل تحديث للإصدار تحديث لمشروع Node بكل اعتمادياته.
- يفترض أنه يملك قاعدة البيانات، فلا يناسب وضعه فوق قاعدة بيانات موجودة لنظام آخر.
- سجل التدقيق ومسارات المراجعة والدخول الموحد في الخطط المدفوعة، وهي مهمة للجهات التي تحتاج إلى معرفة من نشر ماذا.
Directus: لوحة تحكم و API فوق قاعدة بياناتك
Directus يعمل بطريقة مختلفة، فهو لا يفترض أنه يملك قاعدة البيانات، وإنما يقرأ أي قاعدة بيانات SQL كما هي، الجديدة أو الموجودة لنظام آخر، فيحول جداولها إلى مجموعات Collections لها لوحة تحرير و API بصيغتي REST وGraphQL مباشرة. ويحفظ ما يضيفه على الجداول (طريقة عرض الحقل والعلاقات والصلاحيات) في جداول خاصة به تبدأ ب directus_، وتنقل النموذج بين البيئات بملف Snapshot تأخذه من بيئة وتطبقه على أخرى. ويأتي ك Docker Image رسمية جاهزة. والصورة التالية تقارن بين طريقة الأداتين:

ترخيص Directus: ثلاث مراحل في ست سنوات
ترخيص Directus هو أهم ما تقرأه قبل اختياره، لأنه تغير مرتين، وما تقرؤه في المقالات القديمة لم يعد صحيحاً:
- حتى الإصدار 9: كان Directus بترخيص GPLv3 مفتوح المصدر.
- من الإصدار 10 (أبريل 2023): انتقل إلى Business Source License 1.1، مع منحة استخدام إضافية Additional Use Grant تسمح بالاستخدام في الإنتاج مجاناً ما دام مجموع أموال الجهة Total Finances لا يتجاوز 5 ملايين دولار، ومن يتجاوزها يحتاج إلى ترخيص تجاري، ويتحول كل إصدار إلى GPLv3 بعد ثلاث سنوات من صدوره.
- من الإصدار 12 (يونيو 2026): انتقل كما في ملاحظات الإصدار إلى ترخيص جديد اسمه Monospace Sustainable Core License واختصاره MSCL-1.0-GPL، وأضاف لأول مرة مفتاح ترخيص License Key يفحصه البرنامج نفسه.
والترخيص الحالي يسمح لك باستخدام البرنامج وتعديله لأي غرض ما عدا الاستخدام المنافس Competing Use، أي تقديم Directus نفسه لجهات أخرى كخدمة تنافس عروض الشركة المدفوعة، ويمنع تعطيل فحص مفتاح الترخيص أو الالتفاف عليه، ويمنحك ترخيص GPL-3.0 لكل إصدار بعد مرور أربع سنوات على صدوره. ولأن الترخيص يقيد مجال الاستخدام فهو ليس ترخيصاً مفتوح المصدر بتعريف OSI، وإنما من فئة الكود المتاح Source Available التي شرحناها في مقال التراخيص.
وأما ما تحصل عليه فعلاً، فبحسب صفحة الترخيص وصفحة الأسعار كما قرأناهما في أكتوبر 2026:
- الطبقة الأساسية Core: مجانية لأي جهة مهما كان حجمها ودون مفتاح، وفيها 3 مستخدمين للوحة التحكم Seats، و25 مجموعة Collections، و5 سير عمل Flows، وسجل نشاط ومراجعات لمدة 30 يوماً. والمستخدمون الذين يصلون عبر ال API فقط (مستخدمو تطبيقك وحسابات الخدمات) لا يحسبون من المقاعد.
- ما لا تحصل عليه في Core: الدخول الموحد SSO، وقواعد الصلاحيات المخصصة Custom Permission Rules (مثل «الجمهور يرى المنشور فقط»)، وإيقاف إرسال بيانات الاستخدام Telemetry، وسوف ترى أثر الثانية في التجربة.
- منحة الابتكار المفتوح Open Innovation Grant: مفتاح مجاني بكل الميزات للجهات التي إيراداتها السنوية أقل من 5 ملايين دولار وعدد موظفيها أقل من 50 موظفاً، وتقدر الأهلية بحسب الجهات التي يدخل ممثلوها إلى لوحة التحكم.
- الخطط المدفوعة: Team بسعر 499 دولاراً شهرياً مع التزام سنوي، وEnterprise بسعر يحدد بالتفاوض.
- الاتصال بالشركة: المفتاح يتحقق دورياً من خدمة الترخيص على
licensing.directus.com، والوضع دون اتصال Offline متاح في Enterprise فقط. وبحسب صفحة بيانات الاستخدام يرسل البرنامج كل 6 ساعات تقريراً فيه رابط مشروعك العام وأعداد المجموعات والعناصر والمستخدمين، وبعض التراخيص تتجاهل المتغيرTELEMETRY=false، وفي صفحة الأسعار إيقافه من ميزات Enterprise فقط.
نقاط القوة:
- يعمل فوق قاعدة بيانات موجودة دون أن يغير شكلها، وهذا لا تجده في Strapi.
- Docker Image رسمية جاهزة، والترقية بتغيير الوسم Tag.
- لوحة تحكم غنية: لوحات Dashboards وسير عمل Flows ومكتبة ملفات، مع GraphQL مضمن دون إضافة.
نقاط الضعف:
- الترخيص ليس مفتوح المصدر، وتغير مرتين، ويقيد الطبقة المجانية بثلاثة مقاعد و25 مجموعة.
- قواعد الصلاحيات حسب قيمة الحقل غير متاحة في Core، فلا تستطيع أن تقول «الجمهور يرى المنشور فقط» دون مفتاح.
- اتصال إلزامي بخدمات الشركة للترخيص وبيانات الاستخدام، إلا في Enterprise.
المقارنة في جدول واحد
| البند | Strapi 5 | Directus 12 |
|---|---|---|
| الترخيص | MIT، وما في مجلدات ee/ تجاري | MSCL-1.0-GPL (Source Available)، ويصبح GPL-3.0 بعد 4 سنوات |
| الطبقة المجانية | دون حدود على المستخدمين أو الأنواع | 3 مقاعد و25 مجموعة و5 Flows، أو منحة لمن هم دون 5 ملايين دولار و50 موظفاً |
| مصدر النموذج | ملفات schema.json في المشروع | قاعدة البيانات، وينقل بملف Snapshot |
| قاعدة بيانات موجودة | غير مناسب | مصمم لذلك |
| GraphQL | إضافة رسمية @strapi/plugin-graphql | مضمن |
| الجمهور يرى المنشور فقط | افتراضياً، لكن status=draft يتجاوزه (انظر التجربة) | يحتاج إلى قاعدة مخصصة، وهي غير متاحة في Core |
| SSO | Enterprise أو إضافة على Growth | Team وما فوق، أو المنحة |
| التشغيل | مشروع Node تبني له Docker Image | Docker Image رسمية |
| بيانات الاستخدام | تستطيع إيقافها | إيقافها في Enterprise فقط |
والخلاصة العملية: إذا كنت تبدأ مشروعاً جديداً وتريد ترخيصاً مفتوح المصدر دون حدود وفريقك يكتب JavaScript، فابدأ ب Strapi. وإذا كانت عندك قاعدة بيانات قائمة تريد لها لوحة تحرير و API بسرعة، وفريقك صغير يقع ضمن المنحة أو ضمن حدود Core، فDirectus يوفر عليك وقتاً كبيراً، بشرط أن تقبل شروط الترخيص والاتصال بالشركة. وتوجد أدوات أخرى في الفئة نفسها مثل Payload بترخيص MIT، ولم نجربها في هذا المقال.
تجربة عملية: النموذج نفسه في Strapi وDirectus
الآن سوف نبني النموذج نفسه في الأداتين: مقالات لها عنوان ومعرف ونص وتصنيف وحالة، ومقالان أحدهما منشور والآخر مسودة لخطة إطلاق لم يحن وقتها، ثم نقرأ المحتوى عبر REST وGraphQL، ونجرب صلاحيات الجمهور والتوكن، ونبحث عن الطريق الذي قد تتسرب منه المسودة. وتحتاج التجربة إلى Docker فقط، وكل المنافذ على 127.0.0.1 فلا يصل إليها أحد من خارج جهازك.
إنشاء مشروع Strapi
Strapi ليس له Docker Image رسمية، لذلك ننشئ المشروع داخل Container فيه Node.js 22 ونحفظه في Volume:
docker network create lab-devplat-net
docker run -d --name lab-devplat-strapi --network lab-devplat-net \
-p 127.0.0.1:41337:1337 -v lab-devplat-strapi-data:/srv -w /srv \
node:22-bookworm-slim sleep infinity
docker exec -e STRAPI_TELEMETRY_DISABLED=true lab-devplat-strapi sh -c \
'npx -y [email protected] cms --non-interactive --skip-cloud --no-run \
--use-npm --js --no-example --no-git-init --no-enable-ab-tests'في الأوامر أعلاه لاحظ أن الخيار --skip-cloud يتخطى خطوة تسجيل الدخول إلى خدمة Strapi السحابية، وأن --non-interactive يختار SQLite لقاعدة البيانات، وهي مناسبة للتجربة فقط، أما في الإنتاج فاستخدم PostgreSQL.
النموذج ملفات في المشروع
بدل أن نضغط الأزرار في Content-Type Builder نكتب ملف النموذج مباشرة، وهو الملف نفسه الذي كانت الواجهة ستنتجه. ملف المقال src/api/article/content-types/article/schema.json:
{
"kind": "collectionType",
"collectionName": "articles",
"info": { "singularName": "article", "pluralName": "articles", "displayName": "Article" },
"options": { "draftAndPublish": true },
"attributes": {
"title": { "type": "string", "required": true },
"slug": { "type": "uid", "targetField": "title", "required": true },
"body": { "type": "richtext" },
"category": { "type": "relation", "relation": "manyToOne", "target": "api::category.category" }
}
}وملف التصنيف src/api/category/content-types/category/schema.json بحقل name فقط ودون مسودات، ولكل نوع ثلاثة ملفات قصيرة تنشئ ال Controller وال Router وال Service الافتراضية، وهذا ملف ال Router مثلاً:
const { factories } = require('@strapi/strapi');
module.exports = factories.createCoreRouter('api::article.article');ثم نضيف إضافة GraphQL ونبني لوحة التحكم ونشغل المشروع في وضع الإنتاج وننشئ حساب المدير:
docker exec lab-devplat-strapi sh -c 'cd /srv/cms && npm install @strapi/[email protected] && NODE_ENV=production npm run build'
docker exec lab-devplat-strapi sh -c 'cd /srv/cms && npx strapi admin:create-user --firstname=Admin [email protected] --password=Lab-Pass-2026'
docker exec -d -e NODE_ENV=production -e STRAPI_TELEMETRY_DISABLED=true lab-devplat-strapi sh -c 'cd /srv/cms && npm run start > /tmp/strapi.log 2>&1'والمخرج سوف يكون كما يلي (اختصرنا الجدول):
Successfully created new admin
│ Environment │ production │
│ Version │ 5.57.0 (node v22.23.3) │
│ Plan │ Community │
│ Database │ sqlite │
warn: Built-in GraphQL operation limits are unbounded or invalid for: depthLimit, maxLimit. Configure these limits (for example: defaultLimit: 25, maxLimit: 100, depthLimit: 10). Custom Apollo validation rules may independently enforce limits. See https://docs.strapi.io/cms/configurations/plugins.
[2026-10-07 17:43:16.308] info: Strapi started successfullyفي المخرج أعلاه لاحظ التالي:
- السطر
Plan: Communityيعني النسخة المجانية بترخيص MIT. - التحذير الخاص ب GraphQL يقول إن عمق الاستعلام وعدد العناصر في الطلب الواحد دون حد، وهذا ما سوف نصلحه بعد قليل.
والصورة التالية من لوحة التحكم بعد إضافة المقالين، وتظهر فيها حالة كل مقال:

وإذا حاولت تعديل النموذج من لوحة التحكم في وضع الإنتاج تجد الحقول مقفلة كما في الصورة التالية، وإذا أرسلت الطلب مباشرة إلى ال API يرفضه Strapi:

{"data":null,"error":{"status":403,"name":"PolicyError","message":"Content-Type Builder modifications are disabled in production mode. Schema changes can only be made when running with autoReload enabled (strapi develop).","details":{}}}التوكن والجمهور في Strapi
ننشئ من لوحة التحكم (أو من API المدير كما فعلنا) توكنين: الأول بصلاحية كاملة Full Access لسكربت يضيف المحتوى ولمدة أسبوع فقط، والثاني للقراءة فقط Read-only لسيرفر البناء. ثم نضيف المقالين، الأول منشور والثاني مسودة بالمعامل status=draft:
S=http://127.0.0.1:41337
curl -s -X POST "$S/api/articles" -H "Authorization: Bearer $FULL" -H 'Content-Type: application/json' \
-d '{"data":{"title":"Install Docker","slug":"install-docker","body":"Steps to install Docker on Ubuntu","category":"em0j0pw523rkfkev5yg8fy5t"}}'
curl -s -X POST "$S/api/articles?status=draft" -H "Authorization: Bearer $FULL" -H 'Content-Type: application/json' \
-d '{"data":{"title":"Launch plan (draft)","slug":"launch-plan","body":"Not ready yet"}}'
curl -s -w ' HTTP %{http_code}\n' "$S/api/articles"والمخرج سوف يكون كما يلي:
{"data":{"id":2,"documentId":"tvyrxg0pd2iox6ch7xgfowr9","title":"Install Docker","slug":"install-docker","body":"Steps to install Docker on Ubuntu","createdAt":"2026-10-07T17:43:58.004Z","updatedAt":"2026-10-07T17:43:58.004Z","publishedAt":"2026-10-07T17:43:58.027Z"},"meta":{}}
{"data":{"id":3,"documentId":"hugul39h1oh66ecnsqh512l2","title":"Launch plan (draft)","slug":"launch-plan","body":"Not ready yet","createdAt":"2026-10-07T17:43:58.469Z","updatedAt":"2026-10-07T17:43:58.469Z","publishedAt":null},"meta":{}}
{"data":null,"error":{"status":403,"name":"ForbiddenError","message":"Forbidden","details":{}}} HTTP 403في المخرج أعلاه لاحظ أن REST في Strapi ينشر مباشرة عند الإنشاء ما لم تطلب المسودة، كما ينص توثيق المعامل status، وأن الجمهور ممنوع افتراضياً من كل شيء، وهذا جيد. والآن نفتح للجمهور القراءة فقط (find وfindOne) على المقالات والتصنيفات من Settings ثم Users & Permissions ثم Public، ونقرأ:
curl -sg "$S/api/articles?fields[0]=title&fields[1]=publishedAt"
curl -sg "$S/api/articles?filters[slug][\$eq]=install-docker&populate=category&fields[0]=title"{"data":[{"id":2,"documentId":"tvyrxg0pd2iox6ch7xgfowr9","title":"Install Docker","publishedAt":"2026-10-07T17:43:58.027Z"}],"meta":{"pagination":{"page":1,"pageSize":25,"pageCount":1,"total":1}}}
{"data":[{"id":2,"documentId":"tvyrxg0pd2iox6ch7xgfowr9","title":"Install Docker","category":{"id":1,"documentId":"em0j0pw523rkfkev5yg8fy5t","name":"Docker","createdAt":"2026-10-07T17:43:57.488Z","updatedAt":"2026-10-07T17:43:57.488Z","publishedAt":"2026-10-07T17:43:57.475Z"}}],"meta":{"pagination":{"page":1,"pageSize":25,"pageCount":1,"total":1}}}الجمهور يرى المقال المنشور فقط، والمسودة مخفية، ويبدو كل شيء صحيحاً. وتوكن سيرفر البناء يستطيع القراءة ولا يستطيع الكتابة:
{"data":null,"error":{"status":403,"name":"ForbiddenError","message":"Forbidden","details":{}}} HTTP 403نكسرها: الجمهور يطلب المسودات
ماذا يحدث إذا أضاف زائر عادي، دون أي توكن، المعامل نفسه الذي استخدمناه لإنشاء المسودة؟
curl -sg "$S/api/articles?status=draft&fields[0]=title&fields[1]=publishedAt"{"data":[{"id":1,"documentId":"tvyrxg0pd2iox6ch7xgfowr9","title":"Install Docker","publishedAt":null},{"id":3,"documentId":"hugul39h1oh66ecnsqh512l2","title":"Launch plan (draft)","publishedAt":null}],"meta":{"pagination":{"page":1,"pageSize":25,"pageCount":1,"total":2}}}وعند تشغيل الأمر سوف تجد أن خطة الإطلاق التي لم تنشر ظهرت لأي شخص يعرف رابط ال API، والسبب أن صلاحية find في Strapi 5 تعني قراءة النوع، والمعامل status يختار النسخة، ولا يوجد في إعدادات الصلاحيات خيار يفصل بين قراءة المنشور وقراءة المسودات. والأمر نفسه يحدث عبر GraphQL:
curl -s -X POST $S/graphql -H 'Content-Type: application/json' \
-d '{"query":"{ articles(status: DRAFT) { title publishedAt } }"}'{"data":{"articles":[{"title":"Install Docker","publishedAt":null},{"title":"Launch plan (draft)","publishedAt":null}]}}وهذه مشكلة حقيقية لأي شركة تكتب إعلاناً أو سعراً أو نتيجة قبل موعد نشرها، فالمسودة في ال Headless CMS ليست سراً ما لم تحمها بنفسك.
الإصلاح: حارس واحد يغطي REST وGraphQL
الحل البسيط الذي يخطر على البال هو تعديل Controller المقالات بحيث يفرض النسخة المنشورة، وقد جربناه فأصلح REST فقط، وبقي GraphQL يعيد المسودات، والسبب أن GraphQL في Strapi لا يمر بال Controller وإنما يستدعي خدمة المستندات Document Service مباشرة. لذلك المكان الصحيح هو Middleware على Document Service، لأن كل قراءة تمر به مهما كان مصدرها. نضعه في src/index.js:
'use strict';
// Drafts are for the admin panel and for API tokens (build server, preview) only.
// This runs inside the Document Service, so it covers REST and GraphQL alike.
const DRAFT_READERS = ['admin', 'content-api-token'];
const READS = ['findMany', 'findOne', 'findFirst', 'count'];
module.exports = {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
const ctx = strapi.requestContext.get();
const who = ctx?.state?.auth?.strategy?.name;
if (ctx && READS.includes(context.action) && !DRAFT_READERS.includes(who)) {
context.params = { ...context.params, status: 'published' };
}
return next();
});
},
bootstrap() {},
};في الكود أعلاه لاحظ التالي:
strapi.requestContext.get()يعطينا الطلب الحالي، وctx.state.auth.strategy.nameهو طريقة المصادقة:adminللوحة التحكم، وcontent-api-tokenللتوكن، وقد قرأنا الاسم الأخير من كود Strapi 5.57.0 نفسه، فتأكد منه عند الترقية.- كل من سواهما، أي الجمهور ومستخدمو التطبيق، يحصل على النسخة المنشورة مهما كتب في
status. - الشرط
ctx &&يترك العمليات التي لا تأتي من طلب HTTP (مثل سكربتات الترحيل) كما هي.
وبعد إعادة التشغيل نعيد الطلبات الأربعة:
--- public REST asks for drafts:
{"data":[{"id":2,"documentId":"tvyrxg0pd2iox6ch7xgfowr9","title":"Install Docker","publishedAt":"2026-10-07T17:43:58.027Z"}],"meta":{"pagination":{"page":1,"pageSize":25,"pageCount":1,"total":1}}}
--- public GraphQL asks for drafts:
{"data":{"articles":[{"title":"Install Docker","publishedAt":"2026-10-07T17:43:58.027Z"}]}}
--- build token REST drafts:
{"data":[{"id":1,"documentId":"tvyrxg0pd2iox6ch7xgfowr9","title":"Install Docker","publishedAt":null},{"id":3,"documentId":"hugul39h1oh66ecnsqh512l2","title":"Launch plan (draft)","publishedAt":null}],"meta":{"pagination":{"page":1,"pageSize":25,"pageCount":1,"total":2}}}
--- build token GraphQL drafts:
{"data":{"articles":[{"title":"Install Docker","publishedAt":null},{"title":"Launch plan (draft)","publishedAt":null}]}}
--- admin panel still sees the draft:
[('Install Docker', 'published'), ('Launch plan (draft)', 'draft')]الجمهور لم يعد يرى المسودة من REST ولا من GraphQL، وتوكن سيرفر البناء يراها لصفحة المعاينة، ولوحة التحكم تعمل كما هي. وأخيراً نضع حدود GraphQL التي حذرنا منها السجل في config/plugins.js إلى جانب ما فيه من إعدادات، بحسب توثيق الإضافة الذي ينص على أن عمق الاستعلام دون حد ما لم تضعه وأن وضعه يقلل خطر هجمات تعطيل الخدمة DoS:
graphql: {
config: {
depthLimit: 7, // no nesting deeper than 7 levels
defaultLimit: 25,
maxLimit: 100, // nobody gets more than 100 entries in one query
landingPage: false, // no GraphQL sandbox page in production
},
},وبعد إعادة التشغيل لم يعد التحذير يظهر في السجل.
Directus: النموذج نفسه عبر ال API
Directus يأتي ك Docker Image رسمية، فنشغله ب SQLite ونعطيه بيانات المدير ورابطه العام:
docker run -d --name lab-devplat-directus --network lab-devplat-net \
-p 127.0.0.1:48055:8055 -v lab-devplat-directus-data:/directus/database \
-e SECRET=lab-secret-change-me-0123456789abcdef -e DB_CLIENT=sqlite3 \
-e DB_FILENAME=/directus/database/data.db \
-e [email protected] -e ADMIN_PASSWORD=Lab-Pass-2026 \
-e PUBLIC_URL=http://127.0.0.1:48055 -e TELEMETRY=false directus/directus:12.4.1وعند أول دخول إلى لوحة التحكم تسألك نافذة عن مفتاح الترخيص، وتعرض عليك الطبقة الأساسية Core دون مفتاح:

ثم نافذة ثانية تطلب بريد مالك المشروع والموافقة على الترخيص واتفاقية معالجة البيانات، ولاحظ أن Directus يرسل هذا البريد إلى الشركة عند تعيينه كما في صفحة بيانات الاستخدام:

بعد ذلك ننشئ النموذج عبر ال API بدل الواجهة، حتى يكون قابلاً للتكرار. نسجل الدخول ونحفظ التوكن، ثم ننشئ مجموعة التصنيفات ومجموعة المقالات بالحقول نفسها، والملف d-articles.json كما يلي:
{
"collection": "articles",
"schema": {},
"meta": { "icon": "article", "archive_field": "status", "archive_value": "archived", "unarchive_value": "draft" },
"fields": [
{ "field": "id", "type": "integer", "schema": { "is_primary_key": true, "has_auto_increment": true }, "meta": { "hidden": true } },
{ "field": "status", "type": "string", "schema": { "default_value": "draft", "is_nullable": false },
"meta": { "interface": "select-dropdown", "options": { "choices": [ { "text": "Draft", "value": "draft" }, { "text": "Published", "value": "published" }, { "text": "Archived", "value": "archived" } ] } } },
{ "field": "title", "type": "string", "schema": { "is_nullable": false } },
{ "field": "slug", "type": "string", "schema": { "is_unique": true, "is_nullable": false } },
{ "field": "body", "type": "text", "meta": { "interface": "input-rich-text-md" } },
{ "field": "category", "type": "integer", "schema": { "foreign_key_table": "categories", "foreign_key_column": "id" }, "meta": { "interface": "select-dropdown-m2o" } }
]
}D=http://127.0.0.1:48055
T=$(curl -s -X POST $D/auth/login -H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"Lab-Pass-2026"}' | python3 -c "import json,sys;print(json.load(sys.stdin)['data']['access_token'])")
curl -s -X POST $D/collections -H "Authorization: Bearer $T" -H 'Content-Type: application/json' -d @d-categories.json
curl -s -X POST $D/collections -H "Authorization: Bearer $T" -H 'Content-Type: application/json' -d @d-articles.json
curl -s -X POST $D/relations -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"collection":"articles","field":"category","related_collection":"categories"}'لاحظ أن الحالة هنا حقل عادي اسمه status وليست نسختين كما في Strapi، فالمسودة في Directus عنصر قيمة حقله draft. وبعد إضافة المقالين نفسيهما تظهر لوحة المحتوى كما يلي:

الجمهور يرى المنشور فقط؟ ليس في Core
في Directus تفتح القراءة للجمهور بإضافة صلاحية إلى سياسة Public، والصلاحية تقبل قاعدة على العناصر، فنكتب الطريقة الصحيحة: الجمهور يقرأ المقالات التي حالتها published فقط:
P=abf8a154-5b1c-4a46-ac9c-7300570f4f17 # id of the Public policy from GET /policies
curl -s -X POST $D/permissions -H "Authorization: Bearer $T" -H 'Content-Type: application/json' -d '[
{ "policy": "'$P'", "collection": "articles", "action": "read", "fields": ["id","title","slug","body","category","status"],
"permissions": { "status": { "_eq": "published" } } },
{ "policy": "'$P'", "collection": "categories", "action": "read", "fields": ["*"], "permissions": {} } ]'{"errors":[{"message":"custom_permission_rules_enabled is a restricted resource.","extensions":{"category":"custom_permission_rules_enabled","code":"RESOURCE_RESTRICTED"}}]}رفض Directus الصلاحية، والسبب أن قواعد الصلاحيات المخصصة من الميزات التي نقلها الإصدار 12 خارج الطبقة الأساسية. وجربنا بعدها تحديد الحقول دون أي قاعدة، فرفضها برسالة الخطأ نفسها، ولم يقبل إلا الصلاحية الكاملة على كل الحقول:
curl -s -X POST $D/permissions -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"policy":"'$P'","collection":"articles","action":"read","fields":["*"]}'
curl -s "$D/items/articles?fields=id,title,status"
curl -sg "$D/items/articles?fields=id,title,status&filter[status][_eq]=published"{"data":{"id":1,"collection":"articles","action":"read","permissions":null,"validation":null,"presets":null,"fields":["*"],"policy":"abf8a154-5b1c-4a46-ac9c-7300570f4f17"}}
{"data":[{"id":1,"title":"Install Docker","status":"published"},{"id":2,"title":"Launch plan (draft)","status":"draft"}]}
{"data":[{"id":1,"title":"Install Docker","status":"published"}]}في المخرج أعلاه لاحظ أن الجمهور أصبح يقرأ المسودة، وأن إخفاءها بالتصفية filter في رابط الطلب لا يحمي شيئاً، لأن الزائر هو من يكتب الرابط ويستطيع حذف التصفية. لذلك في الطبقة الأساسية لا تفتح للجمهور أي مجموعة فيها مسودات، وإنما اجعل الواجهة تقرأ من سيرفرها بتوكن كما سوف نرى الآن، أو احصل على مفتاح المنحة إذا كانت جهتك مؤهلة. وأما القراءة عبر GraphQL فتعمل دون إضافة:
curl -s -X POST $D/graphql -H 'Content-Type: application/json' \
-d '{ "query": "{ articles(filter: { status: { _eq: \"published\" } }) { title slug category { name } } }" }'{"data":{"articles":[{"title":"Install Docker","slug":"install-docker","category":{"name":"Docker"}}]}}الأدوار والمقاعد والتوكن في Directus
ننشئ سياسة Editor تسمح بالدخول إلى لوحة التحكم وإنشاء المقالات وتعديلها دون تغيير النموذج، ثم دوراً Editor، ثم ثلاثة محررين:
editor1:
{"data":{"id":"16247403-d32e-44b5-8a58-aa5c13826e56", … ,"email":"[email protected]", …
editor2:
{"data":{"id":"27994669-bcf6-4993-91f2-76aa51f791e0", … ,"email":"[email protected]", …
editor3:
{"errors":[{"message":"seats limit exceeded.","extensions":{"category":"seats","code":"LIMIT_EXCEEDED"}}]}المدير ومحرران هم المقاعد الثلاثة في Core، والمحرر الثالث رفض. أما سيرفر البناء فننشئ له مستخدماً للـ API فقط بسياسة قراءة دون دخول إلى لوحة التحكم وبتوكن ثابت، وهذا لا يحسب مقعداً:
--- build token can read:
{"data":[{"id":1,"status":"published"},{"id":2,"status":"draft"}]}
--- build token cannot write:
{"errors":[{"message":"You don't have permission to access collection \"articles\" or it does not exist. Queried in root.","extensions":{"reason":"You don't have permission to access collection \"articles\" or it does not exist. Queried in root.","code":"FORBIDDEN"}}]}
--- editor login + update:
{"data":{"id":2,"status":"draft"}}
--- editor cannot change data model:
{"errors":[{"message":"You don't have permission to access this.","extensions":{"code":"FORBIDDEN"}}]} HTTP 403وأخيراً نأخذ Snapshot للنموذج حتى نحفظه في Git وننقله إلى بيئة أخرى بالأمر schema apply:
docker exec lab-devplat-directus node cli.js schema snapshot --yes /tmp/snapshot.yaml
docker exec lab-devplat-directus head -8 /tmp/snapshot.yamlversion: 1
directus: 12.4.1
vendor: sqlite
collections:
- collection: articles
meta:
accountability: all
archive_app_filter: trueالتنظيف
docker rm -f lab-devplat-strapi lab-devplat-directus
docker volume rm lab-devplat-strapi-data lab-devplat-directus-data
docker network rm lab-devplat-netأخطاء شائعة
- الاعتماد على التصفية في رابط الطلب لإخفاء المسودات: الرابط يكتبه الزائر، والحماية تكون في الصلاحيات أو في Middleware على السيرفر كما رأينا.
- توكن بصلاحية كاملة في كود الواجهة: كل من يفتح المتصفح يقرؤه، واجعل التوكن على السيرفر وللقراءة فقط.
- تعديل النموذج مباشرة في الإنتاج: النموذج يتغير في بيئة التطوير ثم ينتقل بالنشر أو بملف Snapshot، والسبب أن التعديل اليدوي في الإنتاج لا يمكن مراجعته ولا تكراره، كما شرحنا في البيئات وGitOps.
- GraphQL دون حدود: ضع حداً للعمق ولعدد العناصر قبل أن تفتح ال API للجمهور، وراجع أمن واجهات API.
- اختيار الأداة دون قراءة الترخيص الحالي: ما كان صحيحاً عن ترخيص Directus قبل يونيو 2026 لم يعد صحيحاً، فاقرأ ملف الترخيص في المستودع في يوم اختيارك.
- SQLite في الإنتاج: مناسب للتجربة، أما في الإنتاج فاستخدم PostgreSQL وخذ له نسخة احتياطية مع مجلد الملفات المرفوعة Uploads.
قائمة تحقق قبل أن تفتح ال API
- اطلب المسودات بدون توكن من REST ومن GraphQL، وتأكد أن الرد لا يحتوي إلا المنشور.
- كل توكن له اسم وغرض ومدة، وتوكن الواجهة للقراءة فقط وعلى السيرفر.
- حدود GraphQL للعمق وعدد العناصر مضبوطة، وصفحة التجربة مغلقة في الإنتاج.
- النموذج في Git (ملفات Strapi أو Snapshot في Directus) ويتغير بطلب دمج.
- ال CMS خلف Reverse Proxy بشهادة TLS، ولوحة التحكم
/adminمحمية بالتحقق الثنائي أو بعنوان داخلي. - قرأت ترخيص الإصدار الذي ستستخدمه، وتعرف ما يرسله السيرفر إلى خارج شبكتك.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- ال Headless CMS يحتفظ بلوحة التحرير وقاعدة البيانات ويقدم المحتوى عبر API لكل الواجهات، وثمنه أنك تبني الواجهة والمعاينة بنفسك، لذلك لا تختره لموقع بسيط.
- نموذج المحتوى تعرفه مرة واحدة فتحصل على الجداول ومسارات REST ومخطط GraphQL، وصلاحيات لوحة التحكم منفصلة عن صلاحيات ال API.
- في Strapi 5 يستطيع الجمهور طلب المسودات بالمعامل
status=draftإذا كانت له صلاحية القراءة، والإصلاح Middleware على Document Service يغطي REST وGraphQL معاً. - Strapi بترخيص MIT للنسخة المجانية والنموذج فيه ملفات في المشروع، وDirectus يعمل فوق أي قاعدة بيانات لكنه منذ الإصدار 12 بترخيص MSCL غير مفتوح المصدر، وطبقته المجانية 3 مقاعد و25 مجموعة دون قواعد صلاحيات مخصصة.
- وفي مقالات قادمة بإذن الله من مرحلة منصات للمطورين نتحدث عن بيئات التطوير في المتصفح، وعن ال Feature Flags وأدواتها، وعن بوابات API.
دليلكيف تصمم API لا تضطر إلى كسره بعد سنةمبادئ تصميم API يتطور دون أن يكسر التطبيقات التي تعتمد عليه، وكيف تختار الشكل المناسب لنظامك.
دليلما هي البرمجيات مفتوحة المصدر؟ القصة وأشهر الرخصكيف بدأت البرمجيات الحرة والمفتوحة، وما الفرق بين الحر والمجاني، وكيف تعرف ما تسمح به رخصة أي مشروع قبل أن تبني عليه.
دليلAuthorization: كيف تجمع قرارات الصلاحيات في مكان واحدلماذا تتحول شروط الصلاحيات المتفرقة في الكود إلى ثغرات، وكيف تنقل القرار إلى محرك سياسات واحد.
دليلمن الفرع إلى الإنتاج: البيئات وGitOps للفرقكيف يختبر فريقك كل ميزة وحدها ويعرضها على العميل دون أن تصل ميزة غير جاهزة إلى الإنتاج.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ تجربة Strapi 5.57.0 مع إضافة GraphQL وDirectus 12.4.1 في Docker، والتحقق من ترخيص كل منهما من المستودع والتوثيق الرسمي.