تخطّى إلى المحتوى الرئيسي
تعدد المستأجرين (Multi-Tenant) في Next.js App Router: ملاحظات ميدانية عن Middleware للنطاقات الفرعية والتسريب الذي رصدناه في الوقت المناسب

تعدد المستأجرين (Multi-Tenant) في Next.js App Router: ملاحظات ميدانية عن Middleware للنطاقات الفرعية والتسريب الذي رصدناه في الوقت المناسب

August 29, 2026
هندسة البرمجيات الشاملة
8 دقيقة قراءة

الخلاصة السريعة

- يمكن لـ middleware في Next.js إعادة كتابة (rewrite) الطلب من `acme.myapp.com` إلى مسار داخلي مثل `/_tenants/acme/...` مع إبقاء شريط العنوان كما هو، وذلك باستخدام `NextResponse.rewrite()` بالاعتماد على ترويسة `host`.

- أصبح middleware يعمل افتراضيًا على بيئة تشغيل Node.js ضمن Fluid Compute، وليس فقط على Edge Runtime، ما يجعل استخدام عميل قاعدة بيانات كامل داخله أمرًا ممكنًا — ومع ذلك نضع طبقة بحث سريعة أمام عملية تحديد المستأجر للحفاظ على زمن استجابة منخفض تحت الحمل المتزامن.

- تعتمد دوال `unstable_cache()` و`fetch()` في تخزينها المؤقت على المفاتيح (arguments) التي تُمرَّر لها فقط. إغفال معرّف المستأجر من هذا المفتاح يجعل كل المستأجرين يتشاركون نفس مدخل التخزين المؤقت — وهذا بالضبط التسريب الذي رصدناه في بيئة الاختبار قبل أن يصل إلى الإنتاج.

- أمان الصفوف (Row-Level Security) في Postgres، المُفعّل عبر تعيين `SET app.tenant_id` مع كل طلب، يُعد خط دفاع أقوى من الاعتماد على تذكّر كل استعلام لإضافة شرط `WHERE tenant_id = ?` — لأنه يفشل بأمان (fail closed) بدلاً من أن يفشل بانكشاف (fail open).

- النطاقات المخصّصة (custom domains) تحتاج جدول ربط بين النطاق والمستأجر، إضافة إلى استخدام Vercel Domains API لتوفير كل نطاق والتحقق منه؛ توجيه النطاقات الفرعية وحده لا يغطي حالة المستأجر الذي يريد نطاقه الخاص في شريط العنوان.

كيف تعمل تعددية المستأجرين المعتمدة على النطاقات الفرعية في Next.js App Router؟

تعددية المستأجرين المعتمدة على النطاقات الفرعية تعني تحديد المستأجر الذي يخصه الطلب انطلاقًا من ترويسة `Host`، ثم توجيه هذا الطلب إلى نفس شجرة App Router التي يشترك فيها جميع المستأجرين، مع بقاء شريط العنوان يعرض النطاق الفرعي الخاص بالمستأجر. في Devya لا نبني مجلدًا منفصلًا لكل عميل — نكتب شجرة واحدة على الشكل `/app/_tenants/[tenant]/dashboard/page.tsx` ونوجّه إليها كل نطاق فرعي وارد.

عملية التوجيه هذه تحدث داخل middleware لأنه يعمل قبل أي مطابقة للمسار، ويمكنه إعادة كتابة مسار الطلب دون الحاجة لإعادة توجيه (redirect):

```ts // middleware.ts import { NextResponse, type NextRequest } from 'next/server'; const ROOT_DOMAIN = 'myapp.com'; export function middleware(request: NextRequest) { const host = request.headers.get('host') || ''; const subdomain = host.endsWith('.' + ROOT_DOMAIN) ? host.replace('.' + ROOT_DOMAIN, '') : null; if (!subdomain || subdomain === 'www') { return NextResponse.next(); } const url = request.nextUrl.clone(); url.pathname = `/_tenants/${subdomain}${url.pathname}`; return NextResponse.rewrite(url); } export const config = { matcher: ['/((?!_next|api/health|favicon.ico).*)'], }; ```

الاستدعاء الأساسي هنا هو `NextResponse.rewrite()`: يستمر المتصفح بعرض `acme.myapp.com/dashboard`، بينما يحل Next.js المسار الداخلي `/app/_tenants/[tenant]/dashboard/page.tsx` من الخلف. إعادة التوجيه (redirect) كانت ستكشف المسار الداخلي في شريط العنوان وتكلّف جولة شبكة إضافية؛ إعادة الكتابة (rewrite) لا تفعل ذلك.

على Vercel، يتطلب هذا إضافة نطاق شامل (wildcard) على شكل `*.myapp.com` إلى المشروع، وسجل DNS شامل يوجّه إلى Vercel — نطاق أساسي واحد وحده لا يلتقط النطاقات الفرعية العشوائية.

كيف نحدد المستأجر داخل middleware دون الحاجة لطلب قاعدة بيانات مع كل زيارة؟

وجود النطاق الفرعي وحده ليس دليلًا على أن المستأجر موجود أو نشط — نطاق مستأجر مُعلّق لا يزال يطابق نمط التوجيه. البحث عن هذه المعلومة في Postgres مع كل طلب يضيف جولة قاعدة بيانات إلى كل تحميل صفحة. لذلك نقلنا هذا البحث — من الاسم المستعار (slug) إلى معرّف المستأجر وخطته وحالته (نشط/معلّق) — إلى Vercel Edge Config، ويتم تحديثه عبر webhook كلما تغيّر سجل المستأجر. القراءة من Edge Config تستغرق أجزاء من المللي ثانية لأن البيانات مُكرَّرة على شبكة الحافة (edge)، مقابل عشرات المللي ثانية التي تكلفها جولة باردة إلى Postgres من داخل middleware.

```ts import { get } from '@vercel/edge-config'; type TenantMeta = { id: string; status: 'active' | 'suspended' }; const tenant = await get<TenantMeta>(`tenant:${subdomain}`); if (!tenant || tenant.status === 'suspended') { return NextResponse.redirect(new URL('/not-found', request.url)); } const headers = new Headers(request.headers); headers.set('x-tenant-id', tenant.id); return NextResponse.rewrite(url, { request: { headers } }); ```

توجيه ترويسة `x-tenant-id` بدلاً من الاكتفاء بالاسم المستعار داخل المسار هو ما يسمح لكل Server Component وRoute Handler لاحقًا بقراءة المستأجر عبر `headers()` دون إعادة اشتقاقه من المسار في كل طبقة.

لماذا شاهد أحد المستأجرين بيانات لوحة تحكم مستأجر آخر من الذاكرة المؤقتة؟

كان لدينا ملخص للوحة التحكم — الإيرادات، المستخدمون النشطون، التذاكر المفتوحة — ملفوفًا داخل `unstable_cache()` لتفادي إعادة حسابه مع كل طلب. النسخة الأولى اعتمدت في مفتاح التخزين المؤقت على اسم الاستعلام فقط، دون معرّف المستأجر داخل مصفوفة المفتاح. `unstable_cache()` تخزّن مؤقتًا وفق مصفوفة المفتاح التي تُمرَّر لها، وليس وفق الوسائط (arguments) التي تستقبلها الدالة الملفوفة — فكل مستأجر يستدعي نفس الدالة كان يصيب نفس مدخل التخزين المؤقت، وبالتالي كان أول طلب يملأ الذاكرة المؤقتة يقدّم أرقامه لبقية المستأجرين حتى موعد إعادة التحقق التالي.

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

```ts async function dashboardStatsFor(tenantId: string) { return unstable_cache( () => db.query.stats(tenantId), ['dashboard-stats', tenantId], { tags: [`tenant-${tenantId}`], revalidate: 60 }, )(); } ```

إضافة معرّف المستأجر داخل مصفوفة المفتاح، إلى جانب وسم (tag) خاص بكل مستأجر، حلّت المشكلة — أصبح بإمكاننا الآن إبطال ذاكرة مستأجر واحد دون المساس بذاكرة أي مستأجر آخر. القاعدة نفسها تنطبق على استدعاءات `fetch()` العادية: إن لم يكن معرّف المستأجر جزءًا من الرابط أو مضافًا صراحة عبر `{ next: { tags: [...] } }`، فإن مستأجرَين يستدعيان نفس الرابط الخارجي بمعطيات متطابقة ظاهريًا قد يتصادمان أيضًا. أصبحنا نعامل التحقق من 'هل يحتوي مفتاح التخزين المؤقت على معرّف المستأجر؟' كبند إلزامي في مراجعة الكود، وليس أمرًا متروكًا لذاكرة أي مهندس أثناء التنفيذ.

كيف يجب أن يبدو تحديد نطاق المستأجر في طبقة الوصول إلى البيانات؟

كل جدول يحتوي بيانات خاصة بالمستأجرين يحصل على عمود `tenant_id`، وكل استعلام يمر عبر دالة مساعدة تضيف شرط `WHERE tenant_id = ?` بدلاً من الاعتماد على كل نقطة استدعاء لإضافته يدويًا. هذه الدالة المساعدة تمنع معظم الأخطاء، لكنها تبقى اتفاقية على مستوى التطبيق — استعلام خام أو نقطة نهاية جديدة تتجاوز الدالة المساعدة يمكن أن تُبطلها بصمت.

أمان الصفوف (RLS) في Postgres هو خط الدفاع الذي يفشل بأمان بدلاً من أن يفشل بانكشاف:

```sql ALTER TABLE invoices ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON invoices USING (tenant_id = current_setting('app.tenant_id')::uuid); ```

مع تفعيل RLS، حتى الاستعلام الذي ينسى شرط `WHERE` يعيد صفر صفوف لأي مستأجر آخر، لأن Postgres يفرض السياسة على مستوى الصف بغض النظر عمّا طلبه التطبيق. يقوم الاتصال بتنفيذ `SET app.tenant_id = '...'` في بداية كل طلب، ضمن نفس المعاملة (transaction) التي تخدمه — وهذا يتوافق جيدًا مع مجمّع اتصالات Postgres في البيئات اللاخادمية (serverless)، لأن `SET` يبقى محصورًا بمعاملة ذلك الاتصال المُجمَّع ولا يتسرّب إلى الاتصال التالي.

ماذا يتغيّر عندما يريد أحد المستأجرين استخدام نطاقه الخاص؟

النطاق الفرعي يغطي حالة `acme.myapp.com`، لكن مستأجرًا من فئة المؤسسات يطلب `app.acme.com` يحتاج مسار تحديد إضافي: جدول يربط النطاقات المخصّصة المُتحقَّق منها بمعرّفات المستأجرين، يُفحص داخل نفس middleware قبل التحقق من النطاق الفرعي. توفير النطاق نفسه يمر عبر Vercel Domains API — إضافة النطاق إلى المشروع، إرجاع سجلات DNS التي يحتاج المستأجر ضبطها، ثم متابعة حالة التحقق — بدلًا من مطالبة المستأجر بإرسال طلب CNAME عبر البريد الإلكتروني. تصدر شهادة SSL تلقائيًا فور التحقق من النطاق، وهذا الجزء الذي كان الدعم اليدوي للنطاقات المخصّصة يجعله مرهقًا.

النطاق الفرعي مقابل التوجيه بالمسار مقابل النطاق المخصّص — أيها نبني أولًا؟

- التوجيه بالمسار (`myapp.com/acme/dashboard`) هو الأسرع للإطلاق ولا يحتاج أي عمل على DNS، لكن عزل المستأجرين يعيش فقط داخل الرابط — الكوكيز وسياسات CSP تحتاج عناية إضافية لتفادي تسرّب البيانات بين المستأجرين على نفس المصدر (origin).

- النطاق الفرعي (`acme.myapp.com/dashboard`) هو الخيار الافتراضي المناسب لمعظم منتجات SaaS الموجّهة للشركات — فصل نظيف، وكل مستأجر يشعر وكأنه نسخته الخاصة — مقابل تكلفة نطاق DNS شامل وشهادة TLS شاملة، ودعم للنطاقات الفرعية في بيئة التطوير المحلية.

- النطاق المخصّص (`app.acme.com`) هو ما يطلبه المستأجرون من فئة المؤسسات عندما يريدون علامتهم التجارية في شريط العنوان، وهو يحمل أكبر عبء تشغيلي: تحقق وتوفير وإدارة دورة حياة الشهادة لكل نطاق على حدة.

نطلق التوجيه بالنطاقات الفرعية كخيار افتراضي، ونضيف النطاقات المخصّصة كميزة في الخطط المدفوعة، بدلًا من بناء دعم النطاقات المخصّصة منذ اليوم الأول — فالتحقق من DNS ودورة حياة الشهادات عمل هندسي حقيقي لا يستحق العناء قبل أن يطلبه عميل فعليًا.

الأسئلة الشائعة

**س: هل يعمل middleware في Next.js على Edge Runtime أم Node.js؟**

**ج:** ضمن Fluid Compute، يعمل middleware افتراضيًا على بيئة Node.js وليس فقط على Edge Runtime المحدود — واجهات Node الكاملة وعملاء قواعد البيانات متاحة الآن هناك، مع أن طبقة بحث سريعة مثل Edge Config أو Redis أمام تحديد المستأجر تبقي زمن استجابة middleware منخفضًا تحت الحمل المتزامن.

**س: كيف نختبر توجيه النطاقات الفرعية محليًا؟**

**ج:** أسماء النطاقات `*.localhost` تُحل إلى `127.0.0.1` في كل متصفح حديث دون الحاجة لتعديل `/etc/hosts`، لذا تعمل `acme.localhost` و`beta.localhost` مباشرة للاختبار المحلي متعدد المستأجرين.

**س: هل يجب أن يحصل كل مستأجر على قاعدة بيانات منفصلة بدلًا من قاعدة مشتركة بعمود tenant_id؟**

**ج:** معظم منتجات SaaS يجب أن تبدأ بقاعدة بيانات مشتركة، وعمود `tenant_id` على كل جدول، وأمان الصفوف في Postgres للعزل. قاعدة بيانات منفصلة لكل مستأجر عادة ما تكون خطوة لاحقة، يفرضها متطلب امتثال محدد أو نمو مستأجر واحد بما يفوق البنية المشتركة، لا خيارًا افتراضيًا من البداية.

**س: هل يحدد التخزين المؤقت المدمج في Next.js نطاقه تلقائيًا حسب المستأجر؟**

**ج:** لا. تعتمد `unstable_cache()` وذاكرة `fetch()` المؤقتة بشكل صارم على الوسائط والوسوم (tags) المُمرَّرة لها — يجب أن يكون معرّف المستأجر جزءًا صريحًا من ذلك المفتاح، وإلا سيتشارك كل المستأجرين نفس مدخل الذاكرة المؤقتة.

**س: هل يمكن لأمان الصفوف أن يحل محل التحقق من المستأجر في كود التطبيق؟**

**ج:** RLS خط دفاع احتياطي، وليس بديلاً — لا يزال على التطبيق ضبط `app.tenant_id` بشكل صحيح مع كل طلب. ما تضيفه RLS هو نمط فشل يعيد صفر صفوف بدلًا من صفوف مستأجر آخر عند غياب التحقق على مستوى التطبيق.