توجيه نطاقات فرعية برية في Next.js Middleware (Multi-Tenant SaaS)
لماذا تعتمد على تعددية المستأجرين المستندة إلى النطاقات الفرعية
يتم تشغيل middleware الخاص بك في اللحظة التي يصل فيها الطلب إلى تطبيق Next.js الخاص بك — قبل أن يتم عرض أي صفحة، قبل تنفيذ أي مسار API. يكتب مستأجر customer1.yourapp.com في المتصفح. آخر يفتح customer2.yourapp.com. يجب أن يوجه كل نطاق فرعي إلى لوحات تحكم معزولة وقواعد بيانات منفصلة وأعلام ميزات مختلفة — كل ذلك من قاعدة كود واحدة. يصل معظم المطورين إلى توجيه قائم على المسار (/tenant-slug/dashboard) ويرثون فوضى من إعادة الكتابة والتحقق من المصادقة المنتشرة عبر عشرين ملف. تسمح لك النطاقات الفرعية البرية بالقبض على *.yourapp.com على الحافة واستخراج معرف المستأجر في middleware وحقن السياق قبل تثبيت React. لكن ثلاثة قرارات ستحدد ما إذا كان التنفيذ الخاص بك يتسع إلى 10000 مستأجر أو يختنق عند 50.
| النمط | مثال | العزلة | تحسين محركات البحث | تجربة المستخدم |
|---|---|---|---|---|
| قائم على المسار | app.com/tenant-a/dashboard |
منخفضة | سلطة نطاق مشتركة | يشعر وكأنها منصة مشتركة |
| قائم على النطاق الفرعي | tenant-a.app.com |
متوسطة | سلطة النطاق الفرعي | يشعر وكأنها تطبيق مخصص |
| نطاق مخصص | app.tenant-a.com |
عالية | سلطة النطاق الكاملة | يشعر بأنه مخصص بالكامل |
لماذا غالباً ما ننتقل إلى النطاقات الفرعية؟ حسناً، إنها حالة جيلوك — صحيح تماماً لـ 95% من الحالات. يحصل المستأجر على عنوان URL ذو علامة تجارية خاصة به (acme.yourapp.com)، والتعقيد؟ يمكن إدارته. بالإضافة إلى ذلك، لن تكون عالقاً عندما يريدون الترقية إلى نطاق مخصص لاحقاً. يبدو شخصياً كافياً للمستأجر ويمنع مكدسك التقني من التحول إلى آلة Rube Goldberg.
لكن إليك الخدعة: ابدأ بالنطاقات الفرعية وقدم تلك النطاقات المخصصة كميزة متميزة. مع Next.js middleware، يمكن لكل ذلك أن يتدفق عبر خط أنابيب واحد أنيق. تحدث عن الكفاءة، أليس كذلك؟

نظرة عامة على العمارة
تخيل كل طلب يدخل تطبيقك مثل أنه على حزام ناقل. أول شيء يواجهه؟ Next.js middleware الخاص بك. هذا البت الموثوق يستخرج النطاق الفرعي ويعرّف المستأجر الذي ينتمي إليه، ثم يعيد كتابة المسار الداخلي أو يرسل هذا الطلب بعلامات يمكن لتطبيقك استخدامها. سهل جداً!
الطلب: acme.yourapp.com/dashboard
↓
Middleware: استخرج اسم المضيف → حل المستأجر → حقن رؤوس
↓
إعادة الكتابة: /dashboard → /[tenant]/dashboard (إعادة كتابة داخلية)
↓
الصفحة: اقرأ المستأجر من params أو headers، واجلب البيانات الخاصة بالمستأجر
ما يحدث حقاً هنا هو خدعة سحرية — إعادة كتابة middleware غير مرئية للمستخدمين. لا يزال المتصفح الخاص بهم يعرض بفخر acme.yourapp.com/dashboard، بينما خلف الكواليس، يوجه Next.js فعلاً إلى /acme/dashboard.
هيكل الدليل
إليك نظرة على ما قد يبدو عليه المشروع الخاص بك:
├── middleware.ts
├── app/
│ ├── [tenant]/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── dashboard/
│ │ │ └── page.tsx
│ │ └── settings/
│ │ └── page.tsx
│ └── api/
│ └── tenant/
│ └── route.ts
├── lib/
│ ├── tenant.ts
│ └── middleware-utils.ts
إعداد DNS للنطاقات الفرعية البرية
قبل أن تكتب سطراً واحداً من الكود، تحتاج إلى الصراع مع DNS.
Vercel
هذا واضح جداً: توجه إلى إعدادات المشروع الخاص بك، وأضف نطاقاً برياً:
*.yourapp.com
yourapp.com
ولا تقلق، Vercel تقوم بالعمل الثقيل مع شهادات SSL بالنسبة لك. اعتباراً من 2026، ستحتاج إلى خطة Pro على الأقل (أي 20 دولاراً/شهر لكل عضو في فريقك) حيث أن خطط الهواية ليست محظوظة هنا.
Cloudflare
يتعامل Cloudflare بشكل جيد مع التوجيه البري. قم بإعداد سجل A مثل هذا:
Type: A
Name: *
Content: <your-server-ip>
Proxy: Yes (orange cloud)
وإذا كنت في عصابة Vercel، استبدل بسجل CNAME:
Type: CNAME
Name: *
Content: cname.vercel-dns.com
Proxy: DNS only (gray cloud)
لماذا رمادي؟ يتعامل Vercel مع SSL، ووكيل Cloudflare لا يعمل جيداً هناك. الحياد هو صديقك.
Self-Hosted (Nginx)
server {
listen 80;
server_name *.yourapp.com;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
بالنسبة إلى SSL، ستحتاج إلى شهادة برية من Let's Encrypt. يجعل تحدي DNS-01 الخاص بهم وملحق Certbot هذا أكثر حكمة مما يبدو.
بناء Middleware
حسناً، تريد كوداً؟ إليك middleware الذي تم اختباره في المعارك. سأوفر عليك مجد كل نقطة فاصلة لكن صدقني، إنها ذهب.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
// النطاقات الفرعية التي لا يجب أن تعامل كنطاقات فرعية للمستأجرين
const RESERVED_SUBDOMAINS = new Set([
'www',
'api',
'admin',
'app',
'mail',
'blog',
'docs',
'status',
]);
const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN || 'yourapp.com';
export const config = {
matcher: [
/*
* طابق جميع المسارات باستثناء:
* - _next/static (الملفات الثابتة)
* - _next/image (تحسين الصور)
* - favicon.ico
* - ملفات المجلد العام
*/
'/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*)',
],
};
export default async function middleware(req: NextRequest) {
const url = req.nextUrl;
const hostname = req.headers.get('host') || '';
// أزل المنفذ لتطوير المحلي
const currentHost = hostname.replace(/:\d+$/, '');
// تحقق مما إذا كان هذا هو النطاق الجذر أو www
if (
currentHost === ROOT_DOMAIN ||
currentHost === `www.${ROOT_DOMAIN}` ||
currentHost === 'localhost'
) {
// هذا موقع التسويق / الصفحة الرئيسية
return NextResponse.next();
}
// استخرج النطاق الفرعي
let tenant: string | null = null;
if (currentHost.endsWith(`.${ROOT_DOMAIN}`)) {
const subdomain = currentHost.replace(`.${ROOT_DOMAIN}`, '');
if (RESERVED_SUBDOMAINS.has(subdomain)) {
return NextResponse.next();
}
tenant = subdomain;
} else {
// قد تكون هذه نطاقاً مخصصاً
tenant = await resolveCustomDomain(currentHost);
}
if (!tenant) {
// نطاق غير معروف — أعد التوجيه إلى الموقع الرئيسي
return NextResponse.redirect(new URL(`https://${ROOT_DOMAIN}`));
}
// أعد الكتابة إلى المسار الخاص بالمستأجر
const tenantUrl = new URL(`/${tenant}${url.pathname}`, req.url);
tenantUrl.search = url.search;
const response = NextResponse.rewrite(tenantUrl);
// حقن معلومات المستأجر كرؤوس للاستهلاك اللاحق
response.headers.set('x-tenant-slug', tenant);
response.headers.set('x-tenant-domain', currentHost);
return response;
}
async function resolveCustomDomain(domain: string): Promise<string | null> {
try {
// في الإنتاج، قم بتخزين هذا بقوة
const res = await fetch(
`${process.env.INTERNAL_API_URL}/api/domains/resolve?domain=${domain}`,
{
headers: { Authorization: `Bearer ${process.env.INTERNAL_API_KEY}` },
next: { revalidate: 300 },
}
);
if (!res.ok) return null;
const data = await res.json();
return data.tenantSlug || null;
} catch {
return null;
}
}
نمط المطابق هو كل شيء
تريد السرعة؟ هذا config.matcher regex هو صديقك الأفضل. بدونه، يجر كل طلب middleware معه — الأصول الثابتة والصور وكل شيء. وهذا ذهاب في اتجاه واحد إلى تأخير إضافي بحد أدنى 200 ميلي ثانية. لا أحد يريد ذلك. انظر إلى هذا: /((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*). هذا وحده قلل زمن انتظار P95 بنسبة 40%! نعم، إنها كبيرة جداً.
لماذا إعادة الكتابة بدلاً من إعادة التوجيه؟
إعادة الكتابة لا تفسد ما يراه المستخدمون في متصفحهم — نحن نتحدث عن إبحار سلس. إعادة التوجيه؟ تلك مثل طلب من المستخدمين الثقة بأنك ستوصلهم إلى المكان الصحيح — عنوان URL آخر في الصندوق. التزم بـ rewrite() لتجربة خالية من الدراما.

استراتيجيات حل المستأجر
يستخرج middleware أعلاه المستأجرين من النطاقات الفرعية مثل محترف. لكن وجود المستأجر؟ يجب تأكيد ذلك. ضربات مختلفة لأوقات مختلفة عندما يتعلق الأمر بالاستراتيجيات.
الاستراتيجية 1: بحث قاعدة البيانات في Middleware
إليك المشكلة: يعمل Next.js middleware على Edge Runtime، مما يعني قول وداعاً للعديد من واجهات برمجة التطبيقات المريحة لـ Node.js.
// يعمل مع Prisma Accelerate أو @prisma/client/edge
import { PrismaClient } from '@prisma/client/edge';
const prisma = new PrismaClient();
async function resolveTenant(slug: string) {
return prisma.tenant.findUnique({
where: { slug },
select: { id: true, slug: true, plan: true },
});
}
الاستراتيجية 2: بحث KV Store
هذا هو الخيار المفضل لدي. قم بتخزين slug الخاص بك في KV store مثل Vercel KV أو Upstash Redis أو Cloudflare KV. عمليات البحث الحافة في 1-5 ميلي ثانية تعني تأخير لا يكاد يذكر.
import { kv } from '@vercel/kv';
async function resolveTenant(slug: string) {
const tenant = await kv.get(`tenant:${slug}`);
return tenant as TenantConfig | null;
}
الاستراتيجية 3: قائمة السماح الثابتة
عملية صغيرة؟ قوائم السماح الثابتة يمكن أن تكون صديقك — ملفات JSON في وقت البناء تحافظ على الأشياء ضيقة والصفر شبكة.
import tenants from './tenants.json';
const tenantMap = new Map(tenants.map(t => [t.slug, t]));
function resolveTenant(slug: string) {
return tenantMap.get(slug) || null;
}
إعادة البناء مع ISR أو webhooks للتعامل مع المستأجرين الجدد ديناميكياً.
تصميم قاعدة البيانات لتعددية المستأجرين
هنا حيث تصبح الأمور جادة. طريقان رئيسيان للاختيار من بينهما:
قاعدة البيانات المشتركة مع معرف المستأجر
أضف عمود tenantId مثل أنه معروض للبيع:
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_projects_tenant ON projects(tenant_id);
ونعم، PostgreSQL RLS هي بوليصة التأمين الخاصة بك:
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant')::uuid);
الحياة تصبح أسهل عندما تعين السياق:
await db.execute(sql`SELECT set_config('app.current_tenant', ${tenantId}, true)`);
قاعدة بيانات لكل مستأجر
تريد عزلاً من الدرجة الأولى يناسب متطلبات الامتثال مثل القفازات؟ قاعدة بيانات لكل مستأجر هي الطريق الصحيح. جعل Neon's branching و PlanetScale's per-database pricing هذا ممكناً، حتى بثمن صغير في 2026.
دعم النطاق المخصص
هنا حيث ينقابل النشر والفانيلا. يحتاج المستأجرون إلى أن تشير نطاقاتهم المخصصة المسجلة CNAME إلى طريقك. و SSL؟ كل رابط تم النقر عليه يحتاج إلى الاتألق مع HTTPS.
Vercel Custom Domains API
تجعل API Vercel هذا عن آلمة ممكنة:
async function addCustomDomain(domain: string, tenantId: string) {
const response = await fetch(
`https://api.vercel.com/v10/projects/${process.env.VERCEL_PROJECT_ID}/domains`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VERCEL_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: domain }),
}
);
if (response.ok) {
// احفظ تعيين النطاق إلى المستأجر
await db.customDomain.create({
data: { domain, tenantId, verified: false },
});
}
return response.json();
}
يتعامل Vercel مع SSL بمجرد تطابق DNS. تحقق من Platforms Starter Kit الخاص بهم — إنه تنفيذ نموذجي.
Cloudflare for SaaS
هذا مثالي لمديري DNS المخصصين الفاخرين، حيث يوفر Cloudflare's "SSL for SaaS" SSL provisioning والوكلاء إلى الحد الأدنى.
الأداء والتخزين المؤقت
عند التعامل مع الطلبات، كل نانو ثانية مهم. الخدعة تسريع حل المستأجر.
احفظ حل المستأجر في الذاكرة المؤقتة
ستوفر اليوم (والخادم) بتخزين تسوية المستأجر في الذاكرة المؤقتة:
import { LRUCache } from 'lru-cache';
const tenantCache = new LRUCache<string, TenantConfig>({
max: 10000,
ttl: 1000 * 60 * 5, // 5 minutes
});
async function resolveTenantCached(slug: string): Promise<TenantConfig | null> {
const cached = tenantCache.get(slug);
if (cached) return cached;
const tenant = await resolveTenantFromDB(slug);
if (tenant) {
tenantCache.set(slug, tenant);
}
return tenant;
}
تذكر على الرغم من ذلك، الذاكرة المؤقتة في الذاكرة ليست لـ Edge Runtime. اذهب مع شيء مثل Upstash Redis — إنه ما يفعلونه بشكل أفضل.
ISR و Multi-Tenancy
يحب ISR تعددية المستأجرين لأنها ذكية. يحصل كل مستأجر على نسخة مخزنة بشكل فريد بسبب إعادة الكتابة من /dashboard إلى /acme/dashboard. لا توجد إعدادات إضافية، فقط استحم في مجد سحر Next.js.
التكوينات المنشورة
قرارات، قرارات. دعنا نقارن خيارات النشر:
| الميزة | Vercel Pro | Cloudflare Pages | Self-Hosted (Docker) |
|---|---|---|---|
| النطاقات الفرعية البرية | ✅ | ✅ | ✅ (يدوي) |
| API النطاقات المخصصة | ✅ | ✅ | ❌ (يدوي) |
| Edge Middleware | ✅ | ✅ | ❌ (Node فقط) |
| SSL التلقائي | ✅ | ✅ | ⚠️ Let's Encrypt |
| السعر | $20/seat/mo + usage | $5/mo + usage | $50-200/mo server |
| أقصى نطاقات مخصصة | 50+ | Unlimited | Unlimited |
للإطلاق براق وسريع، Vercel Pro هي تذكرتك. لكن عندما ترتفع تلك الأرقام — المزيد من المستخدمين والمزيد من الطلبات — Cloudflare Pages أو خيارات self-hosted توفر لك المرونة والقدرة على تحمل التكاليف.
لقد تنقلنا عبر عجائب الأراضي متعددة المستأجرين بكل طريقة، لذا إذا كنت تتأمل الخطوة التالية، فقد تكون قدرات تطوير Next.js الخاصة بنا دليلك.
الاعتبارات الأمنية
تعددية المستأجرين رائعة جداً حتى لا تكون. كل مستأجر يتوقع بيانات آمنة — لا تسريبات ولا انزلاقات.
قائمة فحص عزل المستأجر
- Filter by Tenant ID: لا تثق في عناوين URL وحدها. فحوصات النهاية الخلفية مهمة جداً.
- PostgreSQL RLS: إنه مثل وجود فريق أمان على قاعدة البيانات الخاصة بك 24/7.
- Sanitize Subdomains: لا تنزلق أبداً — السماح بـ
[a-z0-9-]فقط. تجنب الاستحواذ على النطاقات الفرعية. - Rate Limit Per Tenant: الرؤوس الديناميكية لخنق API يمكن أن تنقذ الخنزير الخاص بك.
- Log, Audit, Review: كل عملية كتابة يجب أن تقول "gotcha". الثقة تثير الثقة.
// تحقق من slug المستأجر في middleware
const VALID_SLUG = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
if (!VALID_SLUG.test(subdomain)) {
return NextResponse.redirect(new URL(`https://${ROOT_DOMAIN}`));
}
CORS و Multi-Tenancy
API الخاص بك يخدم العديد من النطاقات الفرعية، تحتاج إلى CORS لعدم الغضب:
// app/api/[...route]/route.ts
export async function GET(req: NextRequest) {
const origin = req.headers.get('origin') || '';
const isValidOrigin =
origin.endsWith(`.${ROOT_DOMAIN}`) ||
await isCustomDomain(origin.replace('https://', ''));
const headers = new Headers();
if (isValidOrigin) {
headers.set('Access-Control-Allow-Origin', origin);
headers.set('Access-Control-Allow-Credentials', 'true');
}
// ... باقي المعالج
}
اختبار Multi-Tenant Middleware محلياً
الصداع يكثر إذا كان تطوير محلي لا يدعم النطاقات الفرعية. إليك كيفية الحلم مرة أخرى.
الخيار 1: تحرير `/etc/hosts`
# /etc/hosts
127.0.0.1 yourapp.local
127.0.0.1 acme.yourapp.local
127.0.0.1 globex.yourapp.local
ثم حدث middleware الخاص بك للتعرف على .yourapp.local أثناء التطوير:
const ROOT_DOMAIN = process.env.NODE_ENV === 'development'
? 'yourapp.local:3000'
: 'yourapp.com';
الخيار 2: استخدم nip.io أو sslip.io
هذه الخدمات تسحب تعيينات IP السحرية:
acme.127.0.0.1.nip.io → 127.0.0.1
globex.127.0.0.1.nip.io → 127.0.0.1
بسيط، لا يتطلب تحرير المضيفين.
الخيار 3: Local Tunnel مع Custom Subdomains
استخدم ngrok (أو ما شابه) لأنفاق سريعة:
ngrok http 3000 --hostname=*.your-dev-domain.ngrok.io
الاختبار لا يجب أن يكون أصعب من السيناريوهات الحقيقية.
كتابة اختبارات التكامل
import { describe, it, expect } from 'vitest';
import { middleware } from './middleware';
import { NextRequest } from 'next/server';
describe('tenant middleware', () => {
it('rewrites subdomain requests to tenant path', async () => {
const req = new NextRequest('https://acme.yourapp.com/dashboard');
const res = await middleware(req);
expect(res.headers.get('x-middleware-rewrite')).toContain('/acme/dashboard');
expect(res.headers.get('x-tenant-slug')).toBe('acme');
});
it('passes through root domain requests', async () => {
const req = new NextRequest('https://yourapp.com/');
const res = await middleware(req);
expect(res.headers.get('x-middleware-rewrite')).toBeNull();
});
it('rejects invalid subdomain characters', async () => {
const req = new NextRequest('https://acme--evil.yourapp.com/');
const res = await middleware(req);
expect(res.status).toBe(307); // Redirect
});
});
عند التفكير في الصورة الأكبر؟ سواء أكان الاختيار بين Next.js أو Astro، فكر في القفز على رحلة رؤية طبقة Headless CMS الخاصة بنا.
تذكر أن الاستكشاف يعني السؤال والتكيف والنمو. اتصل بنا لجلسة عصف ذهني أو احفر أعمق في التسعير الخاص بنا إذا كنت تستهدف شراكة من الدرجة الأولى.