بناء نظام إدارة تعليمي بدون رأس باستخدام Supabase و Next.js

لقد بنيت ثلاث منصات LMS خلال السنتين الماضيتين. استخدمت اثنتان منهما حلولاً جاهزة أزلناها في النهاية. الثالثة -- التي عملت فعلاً -- كانت معمارية بدون رأس مبنية على Next.js و Supabase. هذا هو ما سأرشدك خلال بنائه.

الطرح لنظام LMS بدون رأس بسيط: المنصات التجارية مثل Teachable و Thinkific أو حتى Moodle تعطيك تجربة حادية الكتلة حيث نموذج المحتوى والواجهة الأمامية ونظام المصادقة وتدفق الدفع جميعها ملحومة معاً. في اللحظة التي تحتاج فيها إلى شيء مخصص -- بوابات المحتوى القائمة على الأدوار وإنشاء الاختبارات المدعومة بالذكاء الاصطناعي والنشرات متعددة المستأجرين ذات العلامات البيضاء -- تكون تقاتل المنصة بدلاً من بناء الميزات.

مع Supabase التعامل مع قاعدة البيانات والمصادقة والتخزين والاشتراكات الفورية، و Next.js التعامل مع العرض وطبقة API، تحصل على نظام يكون حقاً لك. لا توقفر البائع على جانب المحتوى. التحكم الكامل في تجربة المتعلم. وقاعدة بيانات Postgres يمكنك الاستعلام عنها مباشرة دون جولة HTTP من مكونات الخادم.

دعونا نبنيها.

جدول المحتويات

بناء نظام إدارة تعليمي بدون رأس باستخدام Supabase و Next.js في 2026

لماذا تختار معمارية بدون رأس لنظام LMS

لم تُبنَ منصات LMS التقليدية للويب الحديث. تم بناؤها لأقسام تكنولوجيا المعلومات التي أرادت تحديد صندوق الامتثال. تعكس تجربة المستخدم ذلك.

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

  • يمكن أن تكون واجهتك الأمامية أي شيء. تطبيق ويب Next.js أو تطبيق React Native للجوال أو بوت Slack يوفر دروساً صغيرة. نفس API يخدمهم جميعاً.
  • نموذج المحتوى الخاص بك هو لك. أنت تحدد ما هي "الدورة". ربما تكون سلسلة من دروس markdown. ربما تكون مجموعة من وحدات الفيديو مع تمارين تفاعلية مدمجة. أنت غير مقيد بما يعتقد Moodle أن الدورة يجب أن تكون عليه.
  • أنت تتحكم في البيانات. تقدم المتعلم ونتائج التقييم ومقاييس المشاركة -- كل شيء يعيش في قاعدة بيانات Postgres الخاصة بك. لا توجد تصديرات CSV من لوحة تحكم البائع.

المقابل واضح: يجب عليك بناء المزيد بنفسك. لكن مع Supabase التعامل مع المصادقة والتخزين والفوري وطبقة قاعدة البيانات، كمية الكود المخصص الذي تكتبه بالفعل صغيرة بشكل مفاجئ.

نظرة عامة على المعمارية

إليك المعمارية عالية المستوى لما نبنيه:

┌─────────────────────────────────────────────┐
│                 Next.js 15                   │
│          (App Router + RSC)                  │
│                                              │
│  ┌──────────┐  ┌──────────┐  ┌───────────┐  │
│  │ Server   │  │ Server   │  │ Client    │  │
│  │Components│  │ Actions  │  │Components │  │
│  └────┬─────┘  └────┬─────┘  └─────┬─────┘  │
│       │              │              │        │
│       └──────────────┼──────────────┘        │
│                      │                       │
│              Supabase SSR Client             │
└──────────────────────┬───────────────────────┘
                       │
         ┌─────────────┼─────────────┐
         │             │             │
    ┌────▼────┐  ┌─────▼────┐  ┌────▼─────┐
    │Supabase │  │Supabase  │  │Supabase  │
    │  Auth   │  │ Database │  │ Storage  │
    │         │  │(Postgres)│  │ (S3)     │
    └─────────┘  └──────────┘  └──────────┘
                       │
                 ┌─────▼─────┐
                 │  Realtime  │
                 │ (WebSocket)│
                 └───────────┘

قرارات المعمارية الرئيسية:

القرار الخيار السبب
العرض مكونات الخادم + ISR صفحات كتالوج الدورة ثابتة في الغالب؛ لوحات تحكم المتعلم تحتاج إلى بيانات طازجة
المصادقة Supabase Auth مع PKCE تكامل RLS المدمج، لا توجد خدمة مصادقة منفصلة
قاعدة البيانات Supabase Postgres استعلامات مباشرة من مكونات الخادم، لا يلزم ORM
تخزين الملفات Supabase Storage عناوين URL موقعة لمحتوى الفيديو، الوصول مُنظم بـ RLS
الفوري Supabase Realtime منتديات النقاش وتحديثات التقدم الحية
الدفع Stripe (عبر webhooks) Supabase Edge Functions تتعامل مع معالجة webhook
تنسيق المحتوى MDX مخزنة في قاعدة البيانات محتوى منظم يعرض في مكونات React

مكونات الخادم في Next.js 15 يمكنها الاستعلام عن Supabase مباشرة باستخدام عميل الخادم -- لا يلزم مسار API، لا توجد حدود تسلسل. هذا فوز ضخم لنظام LMS حيث تجلب بيانات الدورة وتقدم المستخدم وحالة التسجيل في كل تحميل صفحة.

تصميم مخطط قاعدة البيانات

المخطط هو العمود الفقري لأي نظام LMS. مررت بما يكفي من التكرارات لأعرف أن إصلاح هذا في وقت مبكر يوفر عليك أسابيع لاحقاً. إليك مخطط يتعامل مع الدورات متعددة المستأجرين والوحدات المنظمة وتتبع التقدم والتقييمات.

-- المنظمات (للتأجير المتعدد / العلامات البيضاء)
CREATE TABLE organizations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  slug TEXT UNIQUE NOT NULL,
  settings JSONB DEFAULT '{}',
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- ملفات المستخدم توسع مصادقة Supabase
CREATE TABLE profiles (
  id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  email TEXT NOT NULL,
  full_name TEXT,
  avatar_url TEXT,
  role TEXT NOT NULL DEFAULT 'learner' CHECK (role IN ('admin', 'instructor', 'learner')),
  organization_id UUID REFERENCES organizations(id),
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- الدورات
CREATE TABLE courses (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
  instructor_id UUID REFERENCES profiles(id),
  title TEXT NOT NULL,
  slug TEXT NOT NULL,
  description TEXT,
  thumbnail_url TEXT,
  status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),
  price_cents INTEGER DEFAULT 0,
  metadata JSONB DEFAULT '{}',
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(organization_id, slug)
);

-- الوحدات (الأقسام داخل دورة)
CREATE TABLE modules (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  course_id UUID REFERENCES courses(id) ON DELETE CASCADE,
  title TEXT NOT NULL,
  sort_order INTEGER NOT NULL DEFAULT 0,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- الدروس (وحدات المحتوى الفردية)
CREATE TABLE lessons (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  module_id UUID REFERENCES modules(id) ON DELETE CASCADE,
  title TEXT NOT NULL,
  content_type TEXT NOT NULL CHECK (content_type IN ('video', 'text', 'quiz', 'assignment')),
  content JSONB NOT NULL DEFAULT '{}',
  sort_order INTEGER NOT NULL DEFAULT 0,
  duration_minutes INTEGER,
  is_free_preview BOOLEAN DEFAULT false,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- التسجيلات
CREATE TABLE enrollments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
  course_id UUID REFERENCES courses(id) ON DELETE CASCADE,
  enrolled_at TIMESTAMPTZ DEFAULT NOW(),
  completed_at TIMESTAMPTZ,
  stripe_payment_id TEXT,
  UNIQUE(user_id, course_id)
);

-- تتبع التقدم
CREATE TABLE lesson_progress (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
  lesson_id UUID REFERENCES lessons(id) ON DELETE CASCADE,
  status TEXT DEFAULT 'not_started' CHECK (status IN ('not_started', 'in_progress', 'completed')),
  progress_pct SMALLINT DEFAULT 0 CHECK (progress_pct BETWEEN 0 AND 100),
  completed_at TIMESTAMPTZ,
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(user_id, lesson_id)
);

-- محاولات الاختبار
CREATE TABLE quiz_attempts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
  lesson_id UUID REFERENCES lessons(id) ON DELETE CASCADE,
  answers JSONB NOT NULL,
  score SMALLINT,
  passed BOOLEAN,
  attempted_at TIMESTAMPTZ DEFAULT NOW()
);

بعض ملاحظات التصميم:

  • عمود content في الدروس هو JSONB. هذا مقصود. يخزن درس الفيديو {"video_url": "...", "transcript": "..."}. يخزن درس النص {"mdx": "..."}. يخزن الاختبار {"questions": [...]}. هذا يعطيك بيانات منظمة دون الحاجة إلى جدول منفصل لكل نوع محتوى.
  • sort_order في الوحدات والدروس. هذا مقصود. إعادة ترتيب السحب والإفلات أمر لا بد منه لأي منشئ دورات. ترتيب الأعداد الصحيحة يجعل هذا بسيطاً.
  • metadata JSONB في الدورات. هذا هو خطأ الهروب الخاص بك. حقول SEO والعلامات التجارية المخصصة وأعلام الميزات -- ضع كل شيء هنا دون هجرات المخطط.

بناء نظام إدارة تعليمي بدون رأس باستخدام Supabase و Next.js في 2026 - المعمارية

إعداد مصادقة Supabase مع التحكم القائم على الأدوار

يعطيك Supabase Auth البريد الإلكتروني/كلمة المرور و OAuth وروابط Magic مجاني. لنظام LMS، تحتاج عادة ثلاث أدوار: مسؤول ومدرس ومتعلم. الحيلة هي مزامنة auth.users من Supabase مع جدول profiles الخاص بك.

أولاً، قم بإعداد مشغل قاعدة بيانات ينشئ ملف تعريف عند تسجيل مستخدم جديد:

CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO public.profiles (id, email, full_name, avatar_url)
  VALUES (
    NEW.id,
    NEW.email,
    NEW.raw_user_meta_data->>'full_name',
    NEW.raw_user_meta_data->>'avatar_url'
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();

الآن، على جانب Next.js، قم بتكوين عميل Supabase SSR. هذا هو نمط 2026 باستخدام @supabase/ssr:

// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // استدعاء من مكون خادم -- تجاهل
          }
        },
      },
    }
  )
}
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}

يقرأ عميل الخادم رموز المصادقة من الملفات الشخصية تلقائياً. في مكون الخادم، يمكنك القيام بهذا:

// app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) redirect('/login')

  const { data: profile } = await supabase
    .from('profiles')
    .select('*, organization:organizations(*)')
    .eq('id', user.id)
    .single()

  const { data: enrollments } = await supabase
    .from('enrollments')
    .select('*, course:courses(*)')
    .eq('user_id', user.id)

  return (
    <div>
      <h1>أهلا بعودتك، {profile?.full_name}</h1>
      {/* عرض الدورات المسجل فيها */}
    </div>
  )
}

لا مسار API. لا استدعاء fetch. استعلام قاعدة بيانات مباشر من مكون الخادم. هذه هي الميزة المعمارية للـ Supabase + App Router من Next.js.

أمان مستوى الصف للمحتوى متعدد المستأجرين

RLS هو ما يجعل هذه المعمارية آمنة بالفعل. بدونها، ستعطي مفتاحك الفردي لأي شخص حق الوصول إلى كل شيء. مع RLS، تفرض قاعدة البيانات نفسها من يمكنه رؤية وتعديل ماذا.

إليك السياسات لجداول النوى:

-- الدورات: أي شخص يمكنه قراءة الدورات المنشورة، المدرسون يديرون دوراتهم الخاصة
ALTER TABLE courses ENABLE ROW LEVEL SECURITY;

CREATE POLICY "الدورات المنشورة مرئية للجميع" ON courses
  FOR SELECT USING (status = 'published');

CREATE POLICY "المدرسون يديرون دوراتهم" ON courses
  FOR ALL USING (
    auth.uid() = instructor_id
  );

CREATE POLICY "المسؤولون يديرون دورات org" ON courses
  FOR ALL USING (
    EXISTS (
      SELECT 1 FROM profiles
      WHERE id = auth.uid()
      AND role = 'admin'
      AND organization_id = courses.organization_id
    )
  );

-- الدروس: المستخدمون المسجلون يمكنهم القراءة، المعاينات المجانية مرئية للجميع
ALTER TABLE lessons ENABLE ROW LEVEL SECURITY;

CREATE POLICY "دروس المعاينة المجانية مرئية للجميع" ON lessons
  FOR SELECT USING (is_free_preview = true);

CREATE POLICY "المستخدمون المسجلون يمكنهم قراءة الدروس" ON lessons
  FOR SELECT USING (
    EXISTS (
      SELECT 1 FROM enrollments e
      JOIN modules m ON m.course_id = e.course_id
      WHERE e.user_id = auth.uid()
      AND m.id = lessons.module_id
    )
  );

-- التقدم: المستخدمون يمكنهم فقط رؤية وتحديث تقدمهم الخاص
ALTER TABLE lesson_progress ENABLE ROW LEVEL SECURITY;

CREATE POLICY "المستخدمون يديرون تقدمهم الخاص" ON lesson_progress
  FOR ALL USING (auth.uid() = user_id);

-- التسجيلات: المستخدمون يرون الخاص بهم، المدرسون يرون تسجيلات دوراتهم
ALTER TABLE enrollments ENABLE ROW LEVEL SECURITY;

CREATE POLICY "المستخدمون يرون تسجيلاتهم الخاصة" ON enrollments
  FOR SELECT USING (auth.uid() = user_id);

CREATE POLICY "المدرسون يرون تسجيلات الدورات" ON enrollments
  FOR SELECT USING (
    EXISTS (
      SELECT 1 FROM courses
      WHERE courses.id = enrollments.course_id
      AND courses.instructor_id = auth.uid()
    )
  );

شيء يخدع الناس: سياسات RLS تراكمية لـ SELECT. إذا مررت أي سياسة، فإن الصف مرئي. لـ INSERT و UPDATE و DELETE، يجب أن تمر جميع السياسات المعمول بها. ضع هذا في الاعتبار عند تصحيح الأخطاء في مشاكل الوصول.

بناء واجهة Next.js App Router الأمامية

إليك بنية المسار التي وجدت أنها تعمل بشكل جيد لنظام LMS:

app/
├── (marketing)/
│   ├── page.tsx              # صفحة الهبوط
│   ├── courses/
│   │   ├── page.tsx           # كتالوج الدورات
│   │   └── [slug]/
│   │       └── page.tsx       # صفحة تفاصيل الدورة / صفحة المبيعات
├── (app)/
│   ├── layout.tsx             # تخطيط مصرح به مع الشريط الجانبي
│   ├── dashboard/
│   │   └── page.tsx           # لوحة تحكم المتعلم
│   ├── learn/
│   │   └── [courseSlug]/
│   │       └── [lessonId]/
│   │           └── page.tsx   # عارض الدرس
│   ├── instructor/
│   │   ├── courses/
│   │   │   ├── page.tsx       # قائمة دورات المدرس
│   │   │   ├── new/
│   │   │   │   └── page.tsx   # إنشاء دورة
│   │   │   └── [id]/
│   │   │       └── edit/
│   │   │           └── page.tsx  # محرر الدورة
├── api/
│   └── webhooks/
│       └── stripe/
│           └── route.ts       # معالج webhook من Stripe

تجميع المسارات (marketing) و (app) يتيح لك استخدام تخطيطات مختلفة دون التأثير على بنية URL. تحصل صفحات التسويق على رأس وتذييل بسيط. يحصل قسم التطبيق على شريط جانبي كامل مع التنقل في الدورات.

هنا إجراء خادم للتسجيل في دورة مجانية:

// app/actions/enroll.ts
'use server'

import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'

export async function enrollInCourse(courseId: string) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    return { error: 'يجب تسجيل الدخول للتسجيل' }
  }

  // تحقق من كون الدورة مجانية
  const { data: course } = await supabase
    .from('courses')
    .select('price_cents')
    .eq('id', courseId)
    .single()

  if (course?.price_cents && course.price_cents > 0) {
    return { error: 'هذه الدورة تتطلب دفع' }
  }

  const { error } = await supabase
    .from('enrollments')
    .insert({ user_id: user.id, course_id: courseId })

  if (error) {
    if (error.code === '23505') {
      return { error: 'أنت بالفعل مسجل في هذه الدورة' }
    }
    return { error: error.message }
  }

  revalidatePath('/dashboard')
  return { success: true }
}

إجراءات الخادم في Next.js 15 هي الطريقة الأنظف للتعامل مع الطفرات. لا مسارات API، لا استدعاءات fetch يدوية. تستدعي النموذج الدالة فقط.

عرض محتوى الدورة وتتبع التقدم

لعارض الدرس، تحتاج إلى شيئين يحدثان في نفس الوقت: عرض المحتوى وتتبع التقدم. هنا نهجي:

// app/(app)/learn/[courseSlug]/[lessonId]/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { LessonViewer } from '@/components/lesson-viewer'
import { ProgressTracker } from '@/components/progress-tracker'

export default async function LessonPage({
  params,
}: {
  params: Promise<{ courseSlug: string; lessonId: string }>
}) {
  const { courseSlug, lessonId } = await params
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) redirect('/login')

  const { data: lesson } = await supabase
    .from('lessons')
    .select(`
      *,
      module:modules(
        *,
        course:courses(*)
      )
    `)
    .eq('id', lessonId)
    .single()

  if (!lesson) redirect('/dashboard')

  // احصل على جميع الدروس في هذه الدورة للتنقل
  const { data: allLessons } = await supabase
    .from('lessons')
    .select('id, title, sort_order, module_id, content_type')
    .eq('module_id', lesson.module_id)
    .order('sort_order')

  // احصل على التقدم الحالي
  const { data: progress } = await supabase
    .from('lesson_progress')
    .select('*')
    .eq('user_id', user.id)
    .eq('lesson_id', lessonId)
    .maybeSingle()

  return (
    <div className="flex h-screen">
      <aside className="w-72 border-r overflow-y-auto">
        {/* الشريط الجانبي للدورة مع قائمة الدروس */}
      </aside>
      <main className="flex-1 overflow-y-auto">
        <LessonViewer
          lesson={lesson}
          progress={progress}
        />
        <ProgressTracker
          lessonId={lessonId}
          initialProgress={progress?.progress_pct ?? 0}
        />
      </main>
    </div>
  )
}

ProgressTracker هو مكون عميل يحدث التقدم مع قراءة المستخدم للمحتوى أو مشاهدة الفيديو. يستخدم عميل Supabase الخاص بالمتصفح لكتابة التقدم في الوقت الفعلي:

// components/progress-tracker.tsx
'use client'

import { useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useDebounce } from '@/hooks/use-debounce'

export function ProgressTracker({
  lessonId,
  initialProgress,
}: {
  lessonId: string
  initialProgress: number
}) {
  const supabase = createClient()

  const updateProgress = useCallback(
    async (pct: number) => {
      await supabase
        .from('lesson_progress')
        .upsert(
          {
            user_id: (await supabase.auth.getUser()).data.user?.id,
            lesson_id: lessonId,
            progress_pct: pct,
            status: pct >= 100 ? 'completed' : 'in_progress',
            completed_at: pct >= 100 ? new Date().toISOString() : null,
          },
          { onConflict: 'user_id,lesson_id' }
        )
    },
    [lessonId, supabase]
  )

  const debouncedUpdate = useDebounce(updateProgress, 2000)

  useEffect(() => {
    const handleScroll = () => {
      const scrollPct = Math.round(
        (window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100
      )
      if (scrollPct > initialProgress) {
        debouncedUpdate(scrollPct)
      }
    }
    window.addEventListener('scroll', handleScroll)
    return () => window.removeEventListener('scroll', handleScroll)
  }, [debouncedUpdate, initialProgress])

  return null // لا يوجد واجهة مستخدم لهذا المكون
}

إلغاء تجميع تحديثات التقدم أمر حرج. أنت لا تريد الوصول إلى قاعدة البيانات في كل حدث تمرير.

الميزات الفورية: المناقشات والإشعارات

يحول Supabase Realtime نظام LMS الخاص بك من عارض محتوى ثابت إلى شيء يشعر أنه حي. الميزتان اللتان أبني دائماً أولاً: مناقشات الدرس والإشعارات للمدرسين.

// components/lesson-discussion.tsx
'use client'

import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'

export function LessonDiscussion({ lessonId }: { lessonId: string }) {
  const supabase = createClient()
  const [messages, setMessages] = useState<any[]>([])

  useEffect(() => {
    // جلب الرسائل الموجودة
    supabase
      .from('discussions')
      .select('*, profile:profiles(full_name, avatar_url)')
      .eq('lesson_id', lessonId)
      .order('created_at')
      .then(({ data }) => setMessages(data ?? []))

    // الاشتراك في الرسائل الجديدة
    const channel = supabase
      .channel(`lesson-${lessonId}`)
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'discussions',
          filter: `lesson_id=eq.${lessonId}`,
        },
        async (payload) => {
          // جلب الرسالة الكاملة مع الملف الشخصي
          const { data } = await supabase
            .from('discussions')
            .select('*, profile:profiles(full_name, avatar_url)')
            .eq('id', payload.new.id)
            .single()
          if (data) setMessages((prev) => [...prev, data])
        }
      )
      .subscribe()

    return () => {
      supabase.removeChannel(channel)
    }
  }, [lessonId, supabase])

  return (
    <div>
      {/* عرض الرسائل ونموذج الإدخال */}
    </div>
  )
}

التخزين وملفات الفيديو باستخدام Supabase Storage

لمحتوى الفيديو، Supabase Storage مع عناوين URL الموقعة هو الطريق. أنشئ مجلد خاص لفيديوهات الدورات واستخدم سياسات تشبه RLS على طبقة التخزين:

-- سياسة مجلد التخزين: فقط المستخدمون المسجلون يمكنهم الوصول إلى فيديوهات الدورات
CREATE POLICY "يمكن للمستخدمين المسجلين عرض فيديوهات الدورات"
ON storage.objects FOR SELECT
USING (
  bucket_id = 'course-videos'
  AND EXISTS (
    SELECT 1 FROM enrollments e
    JOIN courses c ON c.id = e.course_id
    WHERE e.user_id = auth.uid()
    AND storage.filename(name) LIKE c.id || '/%'
  )
);

على جانب الخادم، قم بإنشاء عناوين URL موقعة قصيرة الأجل:

const { data } = await supabase.storage
  .from('course-videos')
  .createSignedUrl(`${courseId}/${lesson.video_filename}`, 3600) // ساعة واحدة

للإنتاج، ستريد وضع CDN أمام هذا. CDN المدمج من Supabase يتعامل مع معظم الحالات، لكن لتسليم الفيديو عالي الحركة، فكر في النسخ مع خدمة مثل Mux أو Cloudflare Stream وتخزين معرفات التشغيل فقط في قاعدة البيانات الخاصة بك.

النشر والاعتبارات المتعلقة بالأداء

يعطيك طبقة Supabase المجانية 500MB قاعدة بيانات و 1GB تخزين و 50,000 مستخدم نشط شهري. هذا كافٍ للإطلاق. تعطيك خطة Pro بـ 25 دولار/شهر 8GB قاعدة بيانات و 100GB تخزين وبدون حد MAU -- يتعامل مع نظام LMS جدي يضم آلاف المتعلمين.

خطة Supabase تخزين قاعدة البيانات تخزين الملفات حد MAU السعر
مجاني 500MB 1GB 50,000 0$
Pro 8GB 100GB بدون حد 25$/شهر
Team 8GB 100GB بدون حد 599$/شهر
Enterprise مخصص مخصص بدون حد مخصص

لنشر Next.js، Vercel هو الخيار الواضح -- تحصل على ISR تلقائي وعتاد Edge وتحسين الصور. لكن إذا كنت حساساً من حيث التكلفة، يمكنك النشر على Coolify أو Railway بتكلفة أقل بكثير.

نصائح الأداء من الإنتاج:

  • استخدم ISR لصفحات كتالوج الدورات. revalidate: 3600 يعني أن كتالوجك يعاد بناؤه مرة واحدة على الأقل في الساعة. سريع بما يكفي لمعظم حالات الاستخدام.
  • استخدم مكونات الخادم لعارض الدرس. تم عرض تحميل الصفحة الأولي مع كل المحتوى. بدون عجلات تحميل.
  • أضف فهارس قاعدة البيانات. على الأقل: enrollments(user_id) و lesson_progress(user_id, lesson_id) و lessons(module_id, sort_order) و courses(organization_id, slug).
  • تجميع الاتصال. استخدم سلسلة اتصال تجميع PgBouncer المدمجة من Supabase لاستعلامات من جانب الخادم. الاتصال المباشر للهجرات.

إذا كنت تبني شيئاً مثل هذا وتريد مساعدة مع المعمارية أو التنفيذ، فإن فريقنا يقوم بتطوير Next.js و بناء أنظمة إدارة المحتوى بدون رأس بانتظام. لقد نقلنا عدة تطبيقات مدعومة من Supabase ونعرف أين توجد الحواف الحادة.

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

هل يمكن لـ Supabase التعامل مع مقياس نظام LMS للإنتاج؟ نعم. يعمل Supabase على Postgres المُدار، والذي يتعامل مع ملايين الصفوف دون كسر. تتضمن خطة Pro تجميع الاتصال عبر PgBouncer، ويمكنك قياس الحوسبة بشكل مستقل. للرجوع إليها، يبلغ Supabase أن العملاء يشغلون 100k+ اتصال متزامن على طبقة Enterprise الخاصة بهم. لمعظم حالات استخدام LMS -- حتى مع عشرات الآلاف من المتعلمين النشطين -- خطة Pro بـ 25 دولار/شهر أكثر من كافية.

كيف تتعامل مع استضافة الفيديو في نظام LMS مدعوم من Supabase؟ Supabase Storage يعمل لتخزين وتقديم ملفات الفيديو مع عناوين URL الموقعة، لكنه ليس منصة فيديو. لفيديو LMS للإنتاج، أوصي باستخدام خدمة فيديو مخصصة مثل Mux ($0.007/دقيقة للترميز، $0.00015/ثانية للتسليم) أو Cloudflare Stream ($1/1000 دقيقة مخزنة، $5/1000 دقيقة مسلمة). قم بتخزين معرفات التشغيل في قاعدة بيانات Supabase واستخدم مشغلات SDK الخاصة بهم على الواجهة الأمامية. هذا يعطيك معدل بت تكيفي وتحليلات و DRM دون بناء أي من هذا بنفسك.

هل App Router من Next.js مستقر بما يكفي لنظام LMS للإنتاج في 2026؟ بالتأكيد. كان App Router مستقراً منذ Next.js 14، وأصلح Next.js 15 الحواف الخشنة المتبقية بإجراءات الخادم والتخزين المؤقت. مكونات الخادم هي ميزة معمارية حقيقية للتطبيقات الثقيلة من حيث البيانات مثل نظام LMS. واجهة برمجية after() في Next.js 15 مفيدة بشكل خاص لتشغيل أحداث التحليلات وتحديثات التقدم دون حجب الاستجابة.

كيف تطبق شهادات إكمال الدورة؟ تتبع الإكمال على مستوى الدرس مع lesson_progress، ثم احسب إكمال الدورة كنسبة مئوية من الدروس المكتملة في جميع الوحدات. عندما يصل المستخدم إلى 100٪، فعّل Supabase Edge Function (أو إجراء خادم Next.js) يولد شهادة PDF باستخدام مكتبة مثل @react-pdf/renderer ويخزنها في Supabase Storage. أضف صفاً إلى جدول certificates مع مسار التخزين وكود التحقق الفريد.

ماذا عن امتثال SCORM؟ SCORM معيار قديم يتحرك معظم منصات LMS الحديثة بعيداً عنه. مع ذلك، إذا كنت بحاجة لدعم SCORM -- عادة للتدريب على الامتثال للشركات -- يمكنك تحليل حزم SCORM وتخزين المحتوى المستخرج في قاعدة بيانات Supabase الخاصة بك. المكتبات مثل pipwerks-scorm-api-wrapper تتعامل مع واجهة برمجية وقت التشغيل. من الممكن لكنه يضيف تعقيداً كبيراً. أود تجنبه ما لم يطلبه عميل معين.

كيف تتعامل مع الدفع للدورات المدفوعة؟ Stripe Checkout هو المسار الأبسط. أنشئ جلسة Stripe Checkout من إجراء الخادم، وأعد توجيه المستخدم إلى Stripe، وتعامل مع webhook checkout.session.completed في مسار API من Next.js أو Supabase Edge Function. ينشئ webhook سجل التسجيل. هذا يبقي منطق الدفع الخاص بك بسيطاً وجاهز PCI دون التعامل مع بيانات بطاقة الائتمان بنفسك.

هل يمكن لهذه المعمارية دعم تطبيق جوال أيضاً؟ هذه واحدة من أكبر مزايا الاختيار بدون رأس. قاعدة بيانات Supabase والمصادقة تعمل بشكل متطابق من تطبيق React Native عبر @supabase/supabase-js. نفس سياسات RLS تحمي البيانات. أنت فقط تبني طبقة واجهة مستخدم مختلفة. رأينا فرقاً تنقل ويب وجوال من نفس خلفية Supabase بدون تغييرات API.

كيف تضيف ميزات AI مثل إنشاء الاختبارات أو ملخص المحتوى؟ قم بتخزين محتوى الدرس كـ JSONB منظم (لا بيانات HTML بلوب). ثم أنشئ Supabase Edge Function ترسل محتوى الدرس إلى LLM (OpenAI أو Anthropic أو نموذج مستضاف ذاتياً) لإنشاء أسئلة اختبار أو ملخصات أو أدلة دراسية. قم بتخزين المحتوى المُولد مرة أخرى في حقول JSONB. نموذج البيانات المنظم الذي صممناه في وقت سابق يجعل هذا واضحاً -- أنظمة AI يمكنها استهلاك حقول محددة دون تحليل ترميز. إذا كنت مهتماً بهذا النوع من العمل، تواصل مع فريقنا -- كنا نبني تكاملات AI في منصات المحتوى طوال العام.