الكتابات/tutorial/2026/06
Tutorial9 يونيو 2026·28 دقيقة

Triplit و Next.js: بناء تطبيق محلي أولاً وفوري في 2026

تعلّم كيفية بناء تطبيق تعاوني فوري يعمل دون اتصال بالكامل باستخدام Triplit و Next.js. يغطي هذا الدليل العملي تصميم المخطط، والتعديلات التفاؤلية، والاستعلامات العلائقية، وخطافات React الحية، وصلاحيات التحكم في الوصول.

ما زالت معظم تطبيقات الويب تتعامل مع الشبكة كاعتماد إلزامي: تضغط زرًا، تنتظر رحلة ذهابًا وإيابًا، وتأمل أن يستجيب الخادم. أما البرمجيات المحلية أولاً (local-first) فتقلب هذا النموذج. تعيش بياناتك في المتصفح أولًا، وتُحلّ عمليات القراءة والكتابة فورًا مقابل ذاكرة تخزين مؤقت محلية، بينما تتولى عملية في الخلفية مزامنة كل شيء مع الخادم ومع العملاء الآخرين في الوقت الفعلي. وعندما ينقطع الاتصال، يستمر التطبيق في العمل. وعندما يعود، تتم تسوية التغييرات تلقائيًا.

Triplit هي قاعدة بيانات مفتوحة المصدر متكاملة الطبقات مبنية خصيصًا لهذا النموذج. تمنحك مخططًا مُنمّطًا (typed)، ومحرك استعلام علائقي يعمل داخل المتصفح، وكتابات تفاؤلية، ومزامنة فورية، وصلاحيات تحكم دقيقة في الوصول — كل ذلك من حزمة TypeScript واحدة. في هذا الدليل ستبني لوحة مهام فريق تعاونية باستخدام Next.js و Triplit، وستراقب التغييرات تنتشر مباشرة عبر علامات تبويب المتصفح حتى أثناء انقطاع الاتصال.

المتطلبات المسبقة

قبل البدء، تأكد من توفر:

  • Node.js 20+ مثبّت
  • معرفة عملية بـ React و Next.js App Router
  • إلمام بـ TypeScript (المخطط بأكمله مُنمّط)
  • محرر أكواد (يُنصح بـ VS Code)

لا حاجة لخبرة في الواجهة الخلفية. تأتي Triplit بخادم مزامنة خاص بها، وستُشغّله محليًا بأمر واحد.

ما الذي ستبنيه

لوحة مهام فورية حيث:

  • تنتمي المهام إلى مشاريع (رابط علائقي RelationMany)
  • يحدث الإنشاء والتعديل والإكمال والحذف بشكل تفاؤلي — تتحدث الواجهة قبل أن يؤكد الخادم
  • تُزامَن التغييرات مباشرة بين علامات التبويب المفتوحة وتنجو من جلسة كاملة دون اتصال
  • تضمن طبقة الصلاحيات أن المستخدمين لا يعدّلون إلا مهامهم الخاصة

في النهاية ستفهم الأركان الأربعة لـ Triplit: المخطط، والعميل، والاستعلامات، والصلاحيات.

الخطوة 1: إعداد المشروع

ابدأ من تطبيق Next.js جديد مع App Router و TypeScript:

npx create-next-app@latest triplit-board \
  --typescript --app --tailwind --eslint --src-dir=false
cd triplit-board

ثبّت حزم Triplit. @triplit/client هو المحرك الأساسي، و@triplit/react يوفّر الخطافات، و@triplit/cli يُشغّل خادم التطوير المحلي ويدير مخططك:

npm install @triplit/client @triplit/react
npm install --save-dev @triplit/cli

هيّئ هيكل مشروع Triplit. ينشئ هذا مجلد triplit/ يحتوي على ملف schema.ts:

npx triplit init

الخطوة 2: تصميم المخطط

مخطط Triplit هو TypeScript خالص. تُعرّف المجموعات وحقولها والعلاقات بينها باستخدام المساعد Schema (يُستورد اصطلاحًا باسم S). افتح triplit/schema.ts واستبدل محتواه:

// triplit/schema.ts
import { Schema as S } from '@triplit/client';
 
export const schema = S.Collections({
  projects: {
    schema: S.Schema({
      id: S.Id(),
      name: S.String(),
      color: S.String({ default: 'indigo' }),
      ownerId: S.String(),
      createdAt: S.Date({ default: S.Default.now() }),
    }),
    relationships: {
      // للمشروع مهام عديدة يشير projectId الخاص بها إليه
      tasks: S.RelationMany('tasks', {
        where: [['projectId', '=', '$id']],
      }),
    },
  },
 
  tasks: {
    schema: S.Schema({
      id: S.Id(),
      title: S.String(),
      completed: S.Boolean({ default: false }),
      priority: S.String({ default: 'medium' }), // low | medium | high
      projectId: S.String(),
      authorId: S.String(),
      tags: S.Set(S.String()),
      createdAt: S.Date({ default: S.Default.now() }),
    }),
    relationships: {
      // المشروع الوحيد الذي تنتمي إليه هذه المهمة
      project: S.RelationById('projects', '$projectId'),
    },
  },
});

بعض النقاط الجديرة بالملاحظة:

  • S.Id() يولّد معرّفًا نصيًا مقاومًا للتصادم عند حذفه أثناء الإدراج.
  • S.Set(S.String()) نوع مجموعة (set) من الدرجة الأولى — مثالي للوسوم، ويندمج بنظافة عبر التعديلات المتزامنة.
  • S.Date({ default: S.Default.now() }) يختم وقت الإنشاء على ساعة مستقلة عن الخادم.
  • S.RelationMany وS.RelationById يعلنان عن علاقات يمكنك لاحقًا تضمينها بـ .Include() في استعلام، فيدمجها المحرك على جانب العميل.

الرمزان $id و$projectId هما متغيرات استعلام: يشيران إلى حقول الكيان الحالي عند حل العلاقة.

الخطوة 3: تشغيل خادم المزامنة المحلي

يجمع خادم تطوير Triplit بين محرك المزامنة ووحدة تحكم إدارية. شغّله:

npx triplit dev

يطبع هذا قيمتين مهمتين: serverUrl (افتراضيًا http://localhost:6543) ورمز خدمة تطوير (وهو JWT). انسخهما إلى .env.local. ولأن العميل يعمل في المتصفح، يجب أن تكون المتغيرات مسبوقة بـ NEXT_PUBLIC_:

# .env.local
NEXT_PUBLIC_TRIPLIT_SERVER_URL=http://localhost:6543
NEXT_PUBLIC_TRIPLIT_TOKEN=eyJhbGciOi...        # رمز التطوير من `triplit dev`

اترك triplit dev يعمل في طرفية خاصة به. يعيد تحميل مخططك تلقائيًا أثناء تعديله.

الخطوة 4: إنشاء العميل

TriplitClient هو قلب تطبيقك. يملك الذاكرة المؤقتة المحلية، ويدير اتصال مزامنة websocket، ويكشف واجهة الاستعلام والتعديل. أنشئ نسخة مشتركة واحدة:

// triplit/client.ts
import { TriplitClient } from '@triplit/client';
import { schema } from './schema';
 
export const triplit = new TriplitClient({
  schema,
  serverUrl: process.env.NEXT_PUBLIC_TRIPLIT_SERVER_URL,
  token: process.env.NEXT_PUBLIC_TRIPLIT_TOKEN,
  storage: 'indexeddb', // احفظ الذاكرة المؤقتة لتبقى البيانات عبر إعادة التحميل وانقطاع الاتصال
  autoConnect: true,
});

ضبط storage: 'indexeddb' هو ما يجعل التطبيق محليًا أولًا حقًا: تعيش الذاكرة المؤقتة في IndexedDB بالمتصفح، فلا تفقد إعادة تحميل الصفحة — أو جلسة كاملة دون اتصال — أي بيانات. تخزين 'memory' الافتراضي جيد للاختبارات لكنه يتبخر عند التحديث.

مهم: أنشئ العميل مرة واحدة بالضبط واستورد النسخة نفسها في كل مكان. إنشاؤه داخل مكوّن React سيُنتج ذاكرة مؤقتة واتصال مزامنة جديدين عند كل تصيير.

الخطوة 5: عرض البيانات الحية باستخدام useQuery

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

أنشئ مكوّن عميل لقائمة المهام:

// app/components/TaskList.tsx
'use client';
 
import { triplit } from '@/triplit/client';
import { useQuery } from '@triplit/react';
 
export function TaskList({ projectId }: { projectId: string }) {
  // بناء استعلام: مهام هذا المشروع، الأحدث أولًا
  const tasksQuery = triplit
    .query('tasks')
    .Where('projectId', '=', projectId)
    .Order('createdAt', 'DESC');
 
  const { results: tasks, fetching, error } = useQuery(triplit, tasksQuery);
 
  if (fetching) return <p className="text-slate-400">جارٍ تحميل المهام...</p>;
  if (error) return <p className="text-red-500">خطأ: {error.message}</p>;
 
  return (
    <ul className="space-y-2">
      {tasks?.map((task) => (
        <li key={task.id} className="flex items-center gap-3 rounded-lg border p-3">
          <input
            type="checkbox"
            checked={task.completed}
            onChange={() =>
              triplit.update('tasks', task.id, (t) => {
                t.completed = !t.completed;
              })
            }
          />
          <span className={task.completed ? 'line-through text-slate-400' : ''}>
            {task.title}
          </span>
          <span className="ml-auto text-xs uppercase text-slate-500">
            {task.priority}
          </span>
        </li>
      ))}
    </ul>
  );
}

باني الاستعلام قابل للتسلسل وكسول — يصف ما تريده لكنه لا يفعل شيئًا حتى يُسلَّم لخطاف أو fetch أو subscribe. تشمل عوامل التصفية المتاحة = و!= و< و> وlike وin وhas (للمجموعات).

الخطوة 6: الإدراج والتحديث بشكل تفاؤلي

الكتابات في Triplit تفاؤلية افتراضيًا. عند استدعاء insert أو update، يُطبَّق التغيير على الذاكرة المؤقتة المحلية فورًا وتعكسه الواجهة في الإطار نفسه؛ ثم تُوضع الكتابة في طابور وتُزامَن مع الخادم في الخلفية. وإذا رفضها الخادم، تتراجع Triplit عن الحالة المحلية.

أضف نموذجًا لإنشاء المهام:

// app/components/NewTaskForm.tsx
'use client';
 
import { useState } from 'react';
import { triplit } from '@/triplit/client';
 
export function NewTaskForm({
  projectId,
  authorId,
}: {
  projectId: string;
  authorId: string;
}) {
  const [title, setTitle] = useState('');
 
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!title.trim()) return;
 
    // يُطبَّق على الذاكرة المؤقتة المحلية فورًا، ويُزامَن في الخلفية
    await triplit.insert('tasks', {
      title: title.trim(),
      projectId,
      authorId,
      priority: 'medium',
      tags: new Set<string>(),
    });
 
    setTitle('');
  }
 
  return (
    <form onSubmit={handleSubmit} className="flex gap-2">
      <input
        className="flex-1 rounded-lg border px-3 py-2"
        placeholder="ما الذي يجب إنجازه؟"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
      />
      <button
        type="submit"
        disabled={!title.trim()}
        className="rounded-lg bg-indigo-600 px-4 py-2 text-white disabled:opacity-50"
      >
        إضافة
      </button>
    </form>
  );
}

لاحظ أنك لا تحدّد id أبدًا — يولّده S.Id(). كما أنك لا تنتظر await رحلة شبكة قبل مسح الحقل: يُحلّ الإدراج مقابل الذاكرة المؤقتة المحلية، فتظهر المهمة الجديدة فورًا.

للتغييرات متعددة الخطوات التي يجب أن تنجح أو تفشل معًا، لُفّها في transact. الكتلة بأكملها ذرّية — إذا أطلقت أي عملية خطأً، تتراجع المعاملة بأكملها:

// نقل كل مهمة من مشروع إلى آخر، بشكل ذرّي
await triplit.transact(async (tx) => {
  const stale = await tx.fetch(
    triplit.query('tasks').Where('projectId', '=', 'archived')
  );
  for (const task of stale) {
    await tx.update('tasks', task.id, (t) => {
      t.projectId = 'inbox';
    });
  }
});

الخطوة 7: دمج البيانات المرتبطة باستخدام Include

ولأن العلاقات معلنة في المخطط، يمكنك سحب الكيانات المرتبطة في استعلام واحد بـ .Include(). يحل المحرك الدمج في الذاكرة المؤقتة المحلية — دون طلب إضافي، ودون سلسلة شلالية.

// app/components/ProjectBoard.tsx
'use client';
 
import { triplit } from '@/triplit/client';
import { useQuery } from '@triplit/react';
 
export function ProjectBoard() {
  // جلب كل مشروع ومهامه في استعلام تفاعلي واحد
  const query = triplit
    .query('projects')
    .Order('createdAt', 'ASC')
    .Include('tasks');
 
  const { results: projects } = useQuery(triplit, query);
 
  return (
    <div className="grid gap-6 md:grid-cols-2">
      {projects?.map((project) => (
        <section key={project.id} className="rounded-xl border p-4">
          <h2 className="mb-2 font-semibold">{project.name}</h2>
          <p className="text-sm text-slate-500">
            {project.tasks?.length ?? 0} مهام
          </p>
        </section>
      ))}
    </div>
  );
}

project.tasks مُنمّط بالكامل بفضل المخطط، فيُكمل محررك تلقائيًا task.title وtask.completed وبقية الحقول.

الخطوة 8: عكس حالة الاتصال

ينبغي للتطبيق المحلي أولًا أن يخبر المستخدم ما إذا كان يتزامن أم يعمل دون اتصال. يمنحك الخطاف useConnectionStatus قيمة تفاعلية يمكنك تصييرها مباشرة:

// app/components/ConnectionBadge.tsx
'use client';
 
import { triplit } from '@/triplit/client';
import { useConnectionStatus } from '@triplit/react';
 
export function ConnectionBadge() {
  const status = useConnectionStatus(triplit);
  const online = status === 'OPEN';
 
  return (
    <span
      className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs ${
        online ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'
      }`}
    >
      <span
        className={`h-2 w-2 rounded-full ${online ? 'bg-emerald-500' : 'bg-amber-500'}`}
      />
      {online ? 'متزامن' : 'دون اتصال — التغييرات محفوظة محليًا'}
    </span>
  );
}

عند انقطاع الاتصال، يظل كل تعديل يستقر في IndexedDB ويُوضع في طابور المزامنة. أعد الاتصال، وتُفرغ Triplit الطابور وتدمج التغييرات البعيدة — دون أي كود لحل التعارضات من جانبك.

الخطوة 9: التحكم بالوصول عبر الصلاحيات

حتى الآن يمكن لأي عميل يحمل رمز التطوير قراءة كل شيء والكتابة فيه. في الإنتاج تُرفق صلاحيات بكل مجموعة. وهي جُمل تصفية تُقيَّم مقابل مطالبات (claims) JWT للمستخدم المُصادَق عليه، فيستطيع الخادم فرض قواعد الوصول وعدم الوثوق بالعميل عمياءً.

أضف كتلة permissions إلى مجموعة tasks. المتغير $token.sub هو مطالبة الموضوع (معرّف المستخدم) من JWT الذي يصدره مزوّد المصادقة:

// triplit/schema.ts (مجموعة tasks، مع الصلاحيات)
tasks: {
  schema: S.Schema({
    id: S.Id(),
    title: S.String(),
    completed: S.Boolean({ default: false }),
    priority: S.String({ default: 'medium' }),
    projectId: S.String(),
    authorId: S.String(),
    tags: S.Set(S.String()),
    createdAt: S.Date({ default: S.Default.now() }),
  }),
  relationships: {
    project: S.RelationById('projects', '$projectId'),
  },
  permissions: {
    authenticated: {
      // يمكن لكل مسجّل دخول قراءة المهام
      read: { filter: [true] },
      // لا يمكنك إنشاء إلا مهام تكون أنت مؤلفها
      insert: { filter: [['authorId', '=', '$token.sub']] },
      // لا يمكنك تعديل أو حذف إلا مهامك الخاصة
      update: { filter: [['authorId', '=', '$token.sub']] },
      delete: { filter: [['authorId', '=', '$token.sub']] },
    },
  },
},

في الإنتاج تستبدل رمز التطوير بـ JWT حقيقي يصكّه مزوّد المصادقة (Clerk أو Supabase Auth أو Auth.js أو موقّع مخصص). تتحقق Triplit من التوقيع مقابل مفتاح عام مُهيَّأ، وتكشف مطالباته باسم $token.* داخل مرشّحات الصلاحيات. كما تُفرَض صلاحيات القراءة كاستعلامات — فلا يستطيع العميل حرفيًا الاشتراك ببيانات لا يُسمح له برؤيتها.

الخطوة 10: ربط كل شيء معًا

اجمع صفحة. ولأن خطافات Triplit تعمل في المتصفح، فكل مستهلك هو مكوّن عميل، لكن هيكل الصفحة نفسه يمكن أن يبقى مكوّن خادم:

// app/page.tsx
import { ProjectBoard } from './components/ProjectBoard';
import { ConnectionBadge } from './components/ConnectionBadge';
 
export default function HomePage() {
  return (
    <main className="mx-auto max-w-3xl p-8">
      <header className="mb-6 flex items-center justify-between">
        <h1 className="text-2xl font-bold">لوحة الفريق</h1>
        <ConnectionBadge />
      </header>
      <ProjectBoard />
    </main>
  );
}

ازرع مشروعًا مرة واحدة من وحدة تحكم المتصفح أو سكربت إعداد ليكون للوحة ما تعرضه:

await triplit.insert('projects', {
  name: 'أسبوع الإطلاق',
  color: 'indigo',
  ownerId: 'user-1',
});

اختبار تنفيذك

تحقق من السلوك المحلي أولًا — هذا الجزء لا يمكن أن يقدّمه تطبيق REST تقليدي:

  1. المزامنة الفورية. افتح التطبيق في علامتي تبويب جنبًا إلى جنب. أضف مهمة في إحداهما؛ تظهر في الأخرى خلال أجزاء من الثانية، دون تحديث.
  2. الكتابات التفاؤلية. قيّد شبكتك إلى "Slow 3G" في DevTools وأضف مهمة. تظل تظهر فورًا — لا تنتظر الواجهة الخادم أبدًا.
  3. المرونة دون اتصال. افتح DevTools، انتقل إلى علامة Network، وبدّل إلى Offline. أضف وأكمل واحذف عدة مهام. يتحوّل المؤشر إلى "دون اتصال — التغييرات محفوظة محليًا". عُد إلى الاتصال: تُزامَن كل التغييرات مع علامة التبويب الأخرى تلقائيًا.
  4. الاستمرارية. أثناء انقطاع الاتصال، أعد تحميل الصفحة بقوة. مهامك ما زالت موجودة، مُقدَّمة من IndexedDB.

إذا اجتازت الأربعة جميعًا، فلديك تطبيق محلي أولًا بحق.

استكشاف الأخطاء وإصلاحها

يتصل العميل لكن لا تُزامَن أي بيانات. تأكد من أن triplit dev ما زال يعمل وأن NEXT_PUBLIC_TRIPLIT_SERVER_URL وNEXT_PUBLIC_TRIPLIT_TOKEN يطابقان مخرجاته تمامًا. رمز قديم من جلسة triplit dev سابقة هو السبب الأكثر شيوعًا.

تغييرات المخطط لا تُطبَّق. يراقب خادم التطوير triplit/schema.ts، لكن العميل يخزّن المخطط القديم في IndexedDB. امسح تخزين IndexedDB للموقع في DevTools، أو ارفع رقم التخزين لإجبار ذاكرة مؤقتة جديدة أثناء التطوير.

Permission denied عند الإدراج. يجب أن تساوي مطالبة sub في JWT قيمة authorId التي تكتبها. أثناء التطوير المحلي يتجاوز رمز خدمة التطوير الصلاحيات؛ يظهر الخطأ فقط بعد الانتقال إلى رمز مستخدم حقيقي.

تحذيرات عدم تطابق الترطيب (hydration). تقرأ مكوّنات Triplit من ذاكرة المتصفح المؤقتة، وهي فارغة أثناء SSR. ضع علامة 'use client' على كل مكوّن يستدعي خطاف Triplit، واعتمد على علامة fetching للرسم الأول.

الخطوات التالية

  • أضف ترقيم الصفحات بـ usePaginatedQuery أو قائمة "تحميل المزيد" لانهائية بـ useInfiniteQuery — كلاهما يتطلب .Limit() على الاستعلام.
  • ادمج مصادقة حقيقية: راجع دليلينا حول Clerk مع Next.js أو Auth.js v5 لصكّ الـ JWT الذي تتحقق منه Triplit.
  • انشر خادم المزامنة على Triplit Cloud أو استضفه ذاتيًا، ثم وجّه serverUrl إلى نقطة الإنتاج.
  • قارن المقاربات: إن كنت تزن خيارات المحلي أولًا، اقرأ دليلينا حول Zero من Rocicorp وElectricSQL.

الخلاصة

تختزل Triplit طبقات تحتاج عادةً إلى قاعدة بيانات وطبقة API وخادم websocket وذاكرة عميل مؤقتة ومكتبة مزامنة دون اتصال في حزمة واحدة مُنمّطة. لقد عرّفت مخططًا في TypeScript، واستعلمت عنه علائقيًا من المتصفح، وكتبت بشكل تفاؤلي، وراقبت التغييرات تتزامن مباشرة عبر علامات التبويب، وواصلت العمل دون اتصال، وفرضت التحكم في الوصول بمرشّحات صلاحيات تصريحية.

النموذج المحلي أولًا ليس مجرد حيلة أداء — بل يغيّر ما يستطيع المستخدمون فعله. تبقى التطبيقات سريعة الاستجابة على الشبكات المتقطعة، وتنجو من المناطق الميتة، وتبدو فورية لأن القراءات والكتابات لا تغادر الجهاز على المسار الحرج. ومع Triplit، تلك القدرة على بُعد npm install ومخطط واحد.