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

نظرة عامة على الهندسة المعمارية
تخيل أن كل طلب يدخل تطبيقك وكأنه على حزام ناقل. الشيء الأول الذي يواجهه؟ وسيط Next.js الخاص بك. يسحب هذا الرمز الموثوق اسم المضيف، ويكتشف النطاق الفرعي، ويحدد المستأجر الذي ينتمي إليه، ثم إما يعيد كتابة المسار الداخلي أو يعلم هذا الطلب برؤوس يمكن لتطبيقك استخدامها. سهل جدا!
الطلب: acme.yourapp.com/dashboard
↓
الوسيط: استخرج اسم المضيف → حل المستأجر → حقن الرؤوس
↓
إعادة الكتابة: /dashboard → /[tenant]/dashboard (إعادة كتابة داخلية)
↓
الصفحة: قراءة المستأجر من المعاملات أو الرؤوس، جلب البيانات الخاصة بالمستأجر
ما يحدث حقًا هنا هو خدعة سحرية—إعادة كتابة الوسيط غير مرئية للمستخدمين. يعرض متصفح المستخدم بفخر 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 لك. اعتبارًا من 2025، ستحتاج إلى خطة Pro على الأقل (أي 20 دولارًا/شهريًا لكل عضو في فريقك) لأن خطط الهواية لا حظ لها هنا.
Cloudflare
يتعامل Cloudflare بشكل جيد مع التوجيه البدل. قم بإعداد سجل A مثل هذا:
النوع: A
الاسم: *
المحتوى: <your-server-ip>
الوكيل: نعم (السحابة البرتقالية)
وإذا كنت في عصابة Vercel، استبدل سجل CNAME:
النوع: CNAME
الاسم: *
المحتوى: cname.vercel-dns.com
الوكيل: DNS فقط (السحابة الرمادية)
لماذا رمادي؟ يتعامل 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.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;
}
}
نمط المطابقة كل شيء
هل تريد السرعة؟ تلك regex config.matcher هي أفضل صديق لك. بدونها، كل طلب يسحب الوسيط معه—الأصول الثابتة والصور وكل شيء. وهذا تذكرة في اتجاه واحد إلى 200ms+ من الكمون الإضافي. لا أحد يريد ذلك. انظر إلى هذا: /((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*). وحده قطع كمون P95 الخاص بنا بنسبة 40%! نعم، إنه يبدو وكأنه شيء كبير جدًا.
لماذا إعادة الكتابة بدلًا من إعادة التوجيه؟
إعادة الكتابة لا تفسد ما يراه المستخدمون في متصفحهم—نحن نتحدث عن إبحار سلس. إعادة التوجيه؟ هذه تشبه طلب المستخدمين منك الثقة بأنك ستصلهم إلى المكان الصحيح—عنوان URL آخر في الصندوق. التزم بـ rewrite() لتجربة مستخدم خالية من الدراما.

استراتيجيات حل المستأجر
الوسيط أعلاه ينسحب من المستأجرين من النطاقات الفرعية مثل محترف. لكن وجود المستأجر؟ يجب تأكيد ذلك. استراتيجيات مختلفة للجميع عندما يتعلق الأمر.
الإستراتيجية 1: البحث عن قاعدة البيانات في الوسيط
إليك المشكلة: يعمل وسيط Next.js على 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
هذا واحد هو المفضل لدي. قم بتخزين النقاط الخاصة بك في مخزن KV مثل 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 وتسعير PlanetScale لكل قاعدة بيانات هذا ممكنًا، حتى لثروة صغيرة في 2025.
دعم النطاق المخصص
هذا هو المكان الذي تلتقي فيه النشر والغرور. يحتاج المستأجرون إلى نطاقاتهم المخصصة المسجلة CNAME للإشارة في طريقك. و SSL؟ كل رابط تم النقر عليه يحتاج إلى الرمق باستخدام HTTPS.
Vercel Custom Domains 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. تحقق من مجموعة بدء أنظمتهم الأساسية—إنها تطبيق نموذجي.
Cloudflare for SaaS
هذا مثالي لمديري DNS المخصصين الفاخرين، حيث أن "SSL for SaaS" من Cloudflare يرمي في مزويد SSL وينسخ احتياطيًا.
الأداء والخزن المؤقت
عند معالجة الطلبات، كل نانو ثانية مهم. الحيلة هي تسريع حل المستأجر.
الخزن المؤقت لحل المستأجر
ستنقذ اليوم (والخادم) عن طريق تخزين مؤقت لعمليات بحث المستأجر:
import { LRUCache } from 'lru-cache';
const tenantCache = new LRUCache<string, TenantConfig>({
max: 10000,
ttl: 1000 * 60 * 5, // 5 دقائق
});
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 والتعددية
يحب ISR التعددية لأنها ذكية. يحصل كل مستأجر على نسخة مخزنة مؤقتة بشكل فريد بسبب إعادة الكتابة من /dashboard إلى /acme/dashboard. لا توجد تكوينات إضافية، فقط استمتع بمجد سحر Next.js.
تكوينات النشر
قرارات، قرارات. دعونا نقارن خيارات النشر:
| الميزة | Vercel Pro | Cloudflare Pages | Self-Hosted (Docker) |
|---|---|---|---|
| نطاقات البدل | ✅ | ✅ | ✅ (يدوي) |
| API النطاقات المخصصة | ✅ | ✅ | ❌ (يدوي) |
| وسيط الحافة | ✅ | ✅ | ❌ (Node فقط) |
| SSL التلقائي | ✅ | ✅ | ⚠️ Let's Encrypt |
| التسعير | 20 دولار/مقعد/شهر + الاستخدام | 5 دولار/شهر + الاستخدام | 50-200 دولار/شهر الخادم |
| الحد الأقصى للنطاقات المخصصة | 50+ | غير محدود | غير محدود |
بالنسبة للإطلاق اللامع والسريع، Vercel Pro هي تذكرتك. لكن عندما تتسلق تلك الأرقام—المزيد من المستخدمين والمزيد من الطلبات—خيارات Cloudflare Pages أو self-hosted تمنحك المرونة والقدرة على تحمل التكاليف.
لقد تنقلنا عبر عجائب متعددة المستأجرين مع كل طريقة، لذا إذا كنت تتساءل عن خطوتك التالية، فقد تكون قدرات تطوير Next.js الخاصة بنا دليلك.
اعتبارات الأمان
التعددية رائعة جدًا حتى لا تكون. يتوقع كل مستأجر بيانات آمنة—لا تسريبات، لا انزلاق.
قائمة التحقق من عزل المستأجر
- التصفية حسب معرّف المستأجر: لا تثق في عناوين URL وحدها. يحتاج الفحص من جانب الخادم أيضًا.
- PostgreSQL RLS: إنه مثل وجود تفاصيل أمنية على قاعدة البيانات الخاصة بك 24/7.
- تطهير النطاقات الفرعية: لا تنزلق أبدًا—السماح بـ
[a-z0-9-]فقط. تجنب الاستيلاء على النطاق الفرعي. - حد المعدل لكل مستأجر: الرؤوس الديناميكية للاختناق API يمكن أن تنقذ لحمك.
- تسجيل التدقيق والمراجعة: كل عملية كتابة يجب أن تقول "حسنت." الثقة تغرس الثقة.
// تحقق من صحة slug المستأجر في الوسيط
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 والتعددية
خادم 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');
}
// ... بقية المعالج
}
اختبار وسيط متعدد المستأجر محليًا
الصداع يكثر إذا لم يدعم تطوير محلي النطاقات الفرعية. إليك كيف تحلم مرة أخرى.
الخيار 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
ثم قم بتحديث الوسيط الخاص بك للتعرف على .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: نفق محلي مع نطاقات فرعية مخصصة
استخدم 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); // إعادة توجيه
});
});
عند التفكير في الصورة الأكبر؟ سواء كان الاختيار بين Next.js أو Astro، فكر في قفز إلى رحلة رؤى طبقة headless CMS الخاصة بنا.
تذكر أن الاستكشاف يعني السؤال والتكيف والنمو. اتصل بنا لجلسة عصف ذهني أو اغوص بعمق أكثر في التسعير الخاص بنا إذا كنت تهدف إلى شراكة من الدرجة الأولى.