بناء تطبيق CRUD متكامل باستخدام MongoDB و Mongoose و Next.js 15

AI Bot
بواسطة AI Bot ·

جاري تحميل مشغل تحويل النص إلى كلام الصوتي...

MongoDB هي قاعدة البيانات NoSQL الأكثر شعبية في العالم — و Mongoose يجعلها آمنة وأنيقة في TypeScript. في هذا الدليل ستبني تطبيق إدارة مهام متكامل من الصفر باستخدام MongoDB Atlas و Mongoose ODM و Next.js 15 App Router مع Server Actions.

ماذا ستتعلم

بنهاية هذا الدليل، ستكون قادرًا على:

  • إعداد مجموعة MongoDB Atlas وربطها بـ Next.js
  • تعريف مخططات Mongoose مع استدلال أنواع TypeScript
  • بناء عمليات CRUD كاملة باستخدام Next.js Server Actions
  • تنفيذ التحقق من النماذج باستخدام Zod
  • إضافة البحث والتصفية مع استعلامات MongoDB
  • التعامل مع التقسيم إلى صفحات
  • النشر في بيئة الإنتاج مع أفضل ممارسات إدارة الاتصالات

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

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

  • Node.js 20+ (node --version)
  • خبرة في TypeScript (الأنواع، الواجهات، async/await)
  • معرفة بـ Next.js (App Router, Server Components)
  • حساب MongoDB Atlas (الطبقة المجانية تعمل بشكل ممتاز)
  • محرر أكواد — يُنصح بـ VS Code أو Cursor

لماذا MongoDB + Mongoose؟

MongoDB هي قاعدة بيانات مستندات تخزن البيانات في مستندات مرنة شبيهة بـ JSON. مع Mongoose، تحصل على التحقق من المخططات وأمان الأنواع وواجهة استعلام قوية. إليك المقارنة:

الميزةMongoDB + MongoosePostgreSQL + PrismaSQLite + Drizzle
نموذج البياناتمستندات (JSON)جداول علائقيةجداول علائقية
المخططمرن، اختياريصارم، مطلوبصارم، مطلوب
أمان الأنواعMongoose + TS genericsأنواع مُولّدة تلقائيًاأنواع مُستنتجة
التوسعأفقي (sharding)عمودي (read replicas)ملف واحد
البيانات المتداخلةتضمين أصليأعمدة JSON أو joinsأعمدة JSON أو joins
الطبقة المجانيةAtlas 512 MB للأبدNeon 0.5 GBمحلي، غير محدود

اختر MongoDB عندما تكون بياناتك هرمية بطبيعتها، أو يتطور مخططك بشكل متكرر، أو تحتاج إلى توسع أفقي.


الخطوة 1: إنشاء مشروع Next.js

أنشئ مشروع Next.js 15 جديد مع TypeScript و Tailwind CSS:

npx create-next-app@latest task-manager --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd task-manager

ثبّت حزم MongoDB و Mongoose:

npm install mongoose zod
npm install -D @types/mongoose

هيكل المشروع سيكون كالتالي:

task-manager/
├── src/
│   ├── app/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── tasks/
│   ├── lib/
│   │   ├── mongodb.ts
│   │   └── actions/
│   └── models/
│       └── task.ts
├── .env.local
└── package.json

الخطوة 2: إعداد MongoDB Atlas

إنشاء مجموعة مجانية

  1. اذهب إلى MongoDB Atlas وسجّل الدخول
  2. انقر على Build a Database واختر طبقة M0 Free
  3. اختر مزود السحابة والمنطقة الأقرب لمستخدميك
  4. انقر على Create Deployment

إعداد الوصول

  1. أنشئ مستخدم قاعدة بيانات باسم مستخدم وكلمة مرور
  2. في Network Access، أضف عنوان IP الخاص بك أو Allow Access from Anywhere للتطوير
  3. انقر على Connect واختر Drivers وانسخ سلسلة الاتصال

إضافة سلسلة الاتصال

أنشئ ملف .env.local في جذر المشروع:

MONGODB_URI=mongodb+srv://<username>:<password>@<cluster>.mongodb.net/task-manager?retryWrites=true&w=majority

استبدل <username> و <password> و <cluster> ببيانات Atlas الفعلية.


الخطوة 3: إنشاء أداة اتصال MongoDB

اتصالات MongoDB في بيئات serverless تحتاج معالجة خاصة. يجب تخزين الاتصال مؤقتًا لتجنب استنفاد حوض الاتصالات.

أنشئ src/lib/mongodb.ts:

import mongoose from "mongoose";
 
const MONGODB_URI = process.env.MONGODB_URI!;
 
if (!MONGODB_URI) {
  throw new Error("Please define the MONGODB_URI environment variable in .env.local");
}
 
interface MongooseCache {
  conn: typeof mongoose | null;
  promise: Promise<typeof mongoose> | null;
}
 
declare global {
  var mongooseCache: MongooseCache | undefined;
}
 
const cached: MongooseCache = global.mongooseCache ?? { conn: null, promise: null };
 
if (!global.mongooseCache) {
  global.mongooseCache = cached;
}
 
export async function connectDB(): Promise<typeof mongoose> {
  if (cached.conn) {
    return cached.conn;
  }
 
  if (!cached.promise) {
    const opts = {
      bufferCommands: false,
      maxPoolSize: 10,
    };
 
    cached.promise = mongoose.connect(MONGODB_URI, opts).then((m) => m);
  }
 
  cached.conn = await cached.promise;
  return cached.conn;
}

هذا النمط يخزّن الاتصال مؤقتًا عبر عمليات إعادة التحميل الساخنة في التطوير وعبر استدعاءات الدوال في الإنتاج. المتغير global يبقى محفوظًا عبر عمليات إعادة تحميل وحدات Next.js.


الخطوة 4: تعريف مخطط Mongoose

مخططات Mongoose تحدد شكل المستندات وتوفر التحقق والقيم الافتراضية وخطافات البرمجيات الوسيطة.

أنشئ src/models/task.ts:

import mongoose, { Schema, Document, Model } from "mongoose";
 
export interface ITask {
  title: string;
  description: string;
  status: "todo" | "in-progress" | "done";
  priority: "low" | "medium" | "high";
  dueDate?: Date;
  tags: string[];
  createdAt: Date;
  updatedAt: Date;
}
 
export interface ITaskDocument extends ITask, Document {}
 
const taskSchema = new Schema<ITaskDocument>(
  {
    title: {
      type: String,
      required: [true, "Title is required"],
      trim: true,
      maxlength: [200, "Title cannot exceed 200 characters"],
    },
    description: {
      type: String,
      required: [true, "Description is required"],
      trim: true,
      maxlength: [2000, "Description cannot exceed 2000 characters"],
    },
    status: {
      type: String,
      enum: ["todo", "in-progress", "done"],
      default: "todo",
    },
    priority: {
      type: String,
      enum: ["low", "medium", "high"],
      default: "medium",
    },
    dueDate: {
      type: Date,
    },
    tags: {
      type: [String],
      default: [],
    },
  },
  {
    timestamps: true,
  }
);
 
taskSchema.index({ status: 1, priority: 1 });
taskSchema.index({ title: "text", description: "text" });
taskSchema.index({ createdAt: -1 });
 
const Task: Model<ITaskDocument> =
  mongoose.models.Task || mongoose.model<ITaskDocument>("Task", taskSchema);
 
export default Task;

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

  • timestamps: true يدير تلقائيًا createdAt و updatedAt
  • الفهارس على status و priority تسرّع الاستعلامات المُفلترة
  • فهرس النص على title و description يُمكّن البحث النصي الكامل
  • mongoose.models.Task || يمنع إعادة تجميع النموذج أثناء إعادة التحميل الساخن

الخطوة 5: إنشاء مخططات التحقق بـ Zod

عرّف مخططات التحقق التي تعمل على الخادم والعميل.

أنشئ src/lib/validations/task.ts:

import { z } from "zod";
 
export const createTaskSchema = z.object({
  title: z
    .string()
    .min(1, "Title is required")
    .max(200, "Title cannot exceed 200 characters"),
  description: z
    .string()
    .min(1, "Description is required")
    .max(2000, "Description cannot exceed 2000 characters"),
  status: z.enum(["todo", "in-progress", "done"]).default("todo"),
  priority: z.enum(["low", "medium", "high"]).default("medium"),
  dueDate: z.string().optional().transform((val) => (val ? new Date(val) : undefined)),
  tags: z
    .string()
    .optional()
    .transform((val) => (val ? val.split(",").map((t) => t.trim()).filter(Boolean) : [])),
});
 
export const updateTaskSchema = createTaskSchema.partial();
 
export type CreateTaskInput = z.infer<typeof createTaskSchema>;
export type UpdateTaskInput = z.infer<typeof updateTaskSchema>;

الخطوة 6: بناء Server Actions

Server Actions هي الطريقة الأنظف للتعامل مع إرسال النماذج وتعديل البيانات في Next.js 15. تعمل على الخادم ويمكنها الوصول مباشرة إلى قاعدة البيانات.

أنشئ src/lib/actions/task-actions.ts:

"use server";
 
import { revalidatePath } from "next/cache";
import { connectDB } from "@/lib/mongodb";
import Task from "@/models/task";
import { createTaskSchema, updateTaskSchema } from "@/lib/validations/task";
 
export type ActionState = {
  success: boolean;
  message: string;
  errors?: Record<string, string[]>;
};
 
export async function createTask(
  _prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  await connectDB();
 
  const raw = {
    title: formData.get("title") as string,
    description: formData.get("description") as string,
    status: formData.get("status") as string,
    priority: formData.get("priority") as string,
    dueDate: formData.get("dueDate") as string,
    tags: formData.get("tags") as string,
  };
 
  const result = createTaskSchema.safeParse(raw);
 
  if (!result.success) {
    return {
      success: false,
      message: "فشل التحقق من البيانات",
      errors: result.error.flatten().fieldErrors,
    };
  }
 
  await Task.create(result.data);
  revalidatePath("/tasks");
 
  return { success: true, message: "تم إنشاء المهمة بنجاح" };
}
 
export async function updateTask(
  id: string,
  _prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  await connectDB();
 
  const raw = {
    title: formData.get("title") as string,
    description: formData.get("description") as string,
    status: formData.get("status") as string,
    priority: formData.get("priority") as string,
    dueDate: formData.get("dueDate") as string,
    tags: formData.get("tags") as string,
  };
 
  const result = updateTaskSchema.safeParse(raw);
 
  if (!result.success) {
    return {
      success: false,
      message: "فشل التحقق من البيانات",
      errors: result.error.flatten().fieldErrors,
    };
  }
 
  const task = await Task.findByIdAndUpdate(id, result.data, { new: true });
 
  if (!task) {
    return { success: false, message: "لم يتم العثور على المهمة" };
  }
 
  revalidatePath("/tasks");
  return { success: true, message: "تم تحديث المهمة بنجاح" };
}
 
export async function deleteTask(id: string): Promise<ActionState> {
  await connectDB();
 
  const task = await Task.findByIdAndDelete(id);
 
  if (!task) {
    return { success: false, message: "لم يتم العثور على المهمة" };
  }
 
  revalidatePath("/tasks");
  return { success: true, message: "تم حذف المهمة بنجاح" };
}
 
export async function toggleTaskStatus(
  id: string,
  newStatus: "todo" | "in-progress" | "done"
): Promise<ActionState> {
  await connectDB();
 
  const task = await Task.findByIdAndUpdate(id, { status: newStatus }, { new: true });
 
  if (!task) {
    return { success: false, message: "لم يتم العثور على المهمة" };
  }
 
  revalidatePath("/tasks");
  return { success: true, message: "تم تحديث الحالة" };
}

كل إجراء يتبع نمطًا ثابتًا: اتصال، تحقق، تنفيذ، إعادة تحقق. النوع ActionState يوفر شكلاً متوقعًا لمعالجة النتائج على العميل.


الخطوة 7: بناء طبقة الوصول للبيانات

أنشئ دوال الاستعلام لقراءة البيانات. هذه تعمل في Server Components.

أنشئ src/lib/actions/task-queries.ts:

import { connectDB } from "@/lib/mongodb";
import Task, { ITaskDocument } from "@/models/task";
 
export interface TaskFilters {
  status?: string;
  priority?: string;
  search?: string;
  page?: number;
  limit?: number;
}
 
export interface PaginatedTasks {
  tasks: SerializedTask[];
  total: number;
  page: number;
  totalPages: number;
}
 
export interface SerializedTask {
  _id: string;
  title: string;
  description: string;
  status: "todo" | "in-progress" | "done";
  priority: "low" | "medium" | "high";
  dueDate: string | null;
  tags: string[];
  createdAt: string;
  updatedAt: string;
}
 
function serializeTask(task: ITaskDocument): SerializedTask {
  return {
    _id: task._id.toString(),
    title: task.title,
    description: task.description,
    status: task.status,
    priority: task.priority,
    dueDate: task.dueDate ? task.dueDate.toISOString() : null,
    tags: task.tags,
    createdAt: task.createdAt.toISOString(),
    updatedAt: task.updatedAt.toISOString(),
  };
}
 
export async function getTasks(filters: TaskFilters = {}): Promise<PaginatedTasks> {
  await connectDB();
 
  const { status, priority, search, page = 1, limit = 10 } = filters;
 
  const query: Record<string, unknown> = {};
 
  if (status && status !== "all") {
    query.status = status;
  }
 
  if (priority && priority !== "all") {
    query.priority = priority;
  }
 
  if (search) {
    query.$text = { $search: search };
  }
 
  const skip = (page - 1) * limit;
 
  const [tasks, total] = await Promise.all([
    Task.find(query).sort({ createdAt: -1 }).skip(skip).limit(limit).lean(),
    Task.countDocuments(query),
  ]);
 
  return {
    tasks: (tasks as unknown as ITaskDocument[]).map(serializeTask),
    total,
    page,
    totalPages: Math.ceil(total / limit),
  };
}
 
export async function getTaskById(id: string): Promise<SerializedTask | null> {
  await connectDB();
 
  const task = await Task.findById(id).lean();
 
  if (!task) return null;
 
  return serializeTask(task as unknown as ITaskDocument);
}
 
export async function getTaskStats() {
  await connectDB();
 
  const [total, todo, inProgress, done] = await Promise.all([
    Task.countDocuments(),
    Task.countDocuments({ status: "todo" }),
    Task.countDocuments({ status: "in-progress" }),
    Task.countDocuments({ status: "done" }),
  ]);
 
  return { total, todo, inProgress, done };
}

دالة serializeTask تحوّل مستندات Mongoose إلى كائنات عادية — وهذا ضروري لأن Server Components لا يمكنها تمرير نسخ Mongoose إلى Client Components.


الخطوة 8: بناء صفحة قائمة المهام

أنشئ صفحة المهام الرئيسية كـ Server Component.

أنشئ src/app/tasks/page.tsx:

import Link from "next/link";
import { getTasks, getTaskStats } from "@/lib/actions/task-queries";
import { TaskCard } from "./task-card";
import { TaskFilters } from "./task-filters";
import { Pagination } from "./pagination";
 
interface PageProps {
  searchParams: Promise<{
    status?: string;
    priority?: string;
    search?: string;
    page?: string;
  }>;
}
 
export default async function TasksPage({ searchParams }: PageProps) {
  const params = await searchParams;
  const page = parseInt(params.page || "1", 10);
 
  const [{ tasks, total, totalPages }, stats] = await Promise.all([
    getTasks({
      status: params.status,
      priority: params.priority,
      search: params.search,
      page,
      limit: 10,
    }),
    getTaskStats(),
  ]);
 
  return (
    <div className="max-w-5xl mx-auto px-4 py-8">
      <div className="flex items-center justify-between mb-8">
        <div>
          <h1 className="text-3xl font-bold">المهام</h1>
          <p className="text-gray-500 mt-1">
            {stats.total} إجمالي &middot; {stats.todo} للتنفيذ &middot;{" "}
            {stats.inProgress} قيد التنفيذ &middot; {stats.done} مكتملة
          </p>
        </div>
        <Link
          href="/tasks/new"
          className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
        >
          مهمة جديدة
        </Link>
      </div>
 
      <TaskFilters />
 
      {tasks.length === 0 ? (
        <div className="text-center py-16 text-gray-500">
          <p className="text-lg">لم يتم العثور على مهام</p>
          <p className="mt-2">أنشئ مهمتك الأولى للبدء.</p>
        </div>
      ) : (
        <div className="space-y-4 mt-6">
          {tasks.map((task) => (
            <TaskCard key={task._id} task={task} />
          ))}
        </div>
      )}
 
      {totalPages > 1 && (
        <Pagination currentPage={page} totalPages={totalPages} />
      )}
    </div>
  );
}

الخطوة 9: بناء مكوّن بطاقة المهمة

أنشئ Client Component لكل مهمة مع تبديل الحالة وإجراءات الحذف.

أنشئ src/app/tasks/task-card.tsx:

"use client";
 
import { useTransition } from "react";
import Link from "next/link";
import { deleteTask, toggleTaskStatus } from "@/lib/actions/task-actions";
import type { SerializedTask } from "@/lib/actions/task-queries";
 
const statusColors = {
  todo: "bg-gray-100 text-gray-800",
  "in-progress": "bg-blue-100 text-blue-800",
  done: "bg-green-100 text-green-800",
};
 
const priorityColors = {
  low: "bg-slate-100 text-slate-700",
  medium: "bg-yellow-100 text-yellow-800",
  high: "bg-red-100 text-red-800",
};
 
const nextStatus: Record<string, "todo" | "in-progress" | "done"> = {
  todo: "in-progress",
  "in-progress": "done",
  done: "todo",
};
 
export function TaskCard({ task }: { task: SerializedTask }) {
  const [isPending, startTransition] = useTransition();
 
  const handleStatusToggle = () => {
    startTransition(async () => {
      await toggleTaskStatus(task._id, nextStatus[task.status]);
    });
  };
 
  const handleDelete = () => {
    if (!confirm("هل أنت متأكد من حذف هذه المهمة؟")) return;
    startTransition(async () => {
      await deleteTask(task._id);
    });
  };
 
  return (
    <div
      className={`border rounded-lg p-4 transition ${
        isPending ? "opacity-50" : ""
      }`}
    >
      <div className="flex items-start justify-between">
        <div className="flex-1">
          <div className="flex items-center gap-2 mb-2">
            <button
              onClick={handleStatusToggle}
              className={`px-2 py-1 rounded-full text-xs font-medium ${
                statusColors[task.status]
              }`}
            >
              {task.status}
            </button>
            <span
              className={`px-2 py-1 rounded-full text-xs font-medium ${
                priorityColors[task.priority]
              }`}
            >
              {task.priority}
            </span>
          </div>
          <Link href={`/tasks/${task._id}`} className="group">
            <h3 className="text-lg font-semibold group-hover:text-blue-600 transition">
              {task.title}
            </h3>
          </Link>
          <p className="text-gray-600 mt-1 line-clamp-2">{task.description}</p>
          {task.tags.length > 0 && (
            <div className="flex gap-1 mt-2">
              {task.tags.map((tag) => (
                <span
                  key={tag}
                  className="bg-gray-100 text-gray-600 px-2 py-0.5 rounded text-xs"
                >
                  {tag}
                </span>
              ))}
            </div>
          )}
        </div>
        <div className="flex items-center gap-2 ml-4">
          <Link
            href={`/tasks/${task._id}/edit`}
            className="text-gray-400 hover:text-blue-600 transition"
          >
            تعديل
          </Link>
          <button
            onClick={handleDelete}
            className="text-gray-400 hover:text-red-600 transition"
          >
            حذف
          </button>
        </div>
      </div>
    </div>
  );
}

الخطوة 10: بناء نموذج المهمة

أنشئ مكوّن نموذج قابل لإعادة الاستخدام لإنشاء وتعديل المهام.

أنشئ src/app/tasks/task-form.tsx:

"use client";
 
import { useActionState } from "react";
import { createTask, updateTask, ActionState } from "@/lib/actions/task-actions";
import type { SerializedTask } from "@/lib/actions/task-queries";
 
const initialState: ActionState = {
  success: false,
  message: "",
};
 
export function TaskForm({ task }: { task?: SerializedTask }) {
  const action = task
    ? updateTask.bind(null, task._id)
    : createTask;
 
  const [state, formAction, isPending] = useActionState(action, initialState);
 
  return (
    <form action={formAction} className="space-y-6 max-w-2xl">
      {state.message && !state.success && (
        <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
          {state.message}
        </div>
      )}
 
      {state.success && (
        <div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded">
          {state.message}
        </div>
      )}
 
      <div>
        <label htmlFor="title" className="block text-sm font-medium mb-1">
          العنوان
        </label>
        <input
          id="title"
          name="title"
          type="text"
          defaultValue={task?.title}
          required
          className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
          placeholder="أدخل عنوان المهمة"
        />
        {state.errors?.title && (
          <p className="text-red-500 text-sm mt-1">{state.errors.title[0]}</p>
        )}
      </div>
 
      <div>
        <label htmlFor="description" className="block text-sm font-medium mb-1">
          الوصف
        </label>
        <textarea
          id="description"
          name="description"
          defaultValue={task?.description}
          required
          rows={4}
          className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
          placeholder="صف المهمة"
        />
        {state.errors?.description && (
          <p className="text-red-500 text-sm mt-1">
            {state.errors.description[0]}
          </p>
        )}
      </div>
 
      <div className="grid grid-cols-2 gap-4">
        <div>
          <label htmlFor="status" className="block text-sm font-medium mb-1">
            الحالة
          </label>
          <select
            id="status"
            name="status"
            defaultValue={task?.status || "todo"}
            className="w-full border rounded-lg px-3 py-2"
          >
            <option value="todo">للتنفيذ</option>
            <option value="in-progress">قيد التنفيذ</option>
            <option value="done">مكتملة</option>
          </select>
        </div>
 
        <div>
          <label htmlFor="priority" className="block text-sm font-medium mb-1">
            الأولوية
          </label>
          <select
            id="priority"
            name="priority"
            defaultValue={task?.priority || "medium"}
            className="w-full border rounded-lg px-3 py-2"
          >
            <option value="low">منخفضة</option>
            <option value="medium">متوسطة</option>
            <option value="high">عالية</option>
          </select>
        </div>
      </div>
 
      <div>
        <label htmlFor="dueDate" className="block text-sm font-medium mb-1">
          تاريخ الاستحقاق (اختياري)
        </label>
        <input
          id="dueDate"
          name="dueDate"
          type="date"
          defaultValue={task?.dueDate ? task.dueDate.split("T")[0] : ""}
          className="w-full border rounded-lg px-3 py-2"
        />
      </div>
 
      <div>
        <label htmlFor="tags" className="block text-sm font-medium mb-1">
          الوسوم (مفصولة بفواصل)
        </label>
        <input
          id="tags"
          name="tags"
          type="text"
          defaultValue={task?.tags.join(", ")}
          className="w-full border rounded-lg px-3 py-2"
          placeholder="frontend, urgent, bug"
        />
      </div>
 
      <button
        type="submit"
        disabled={isPending}
        className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition"
      >
        {isPending
          ? task
            ? "جاري التحديث..."
            : "جاري الإنشاء..."
          : task
          ? "تحديث المهمة"
          : "إنشاء المهمة"}
      </button>
    </form>
  );
}

الخطوة 11: بناء مكوّن التصفية

أنشئ src/app/tasks/task-filters.tsx:

"use client";
 
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useState } from "react";
 
export function TaskFilters() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const [search, setSearch] = useState(searchParams.get("search") || "");
 
  const updateFilter = useCallback(
    (key: string, value: string) => {
      const params = new URLSearchParams(searchParams.toString());
      if (value && value !== "all") {
        params.set(key, value);
      } else {
        params.delete(key);
      }
      params.delete("page");
      router.push(`/tasks?${params.toString()}`);
    },
    [router, searchParams]
  );
 
  const handleSearch = (e: React.FormEvent) => {
    e.preventDefault();
    updateFilter("search", search);
  };
 
  return (
    <div className="flex flex-wrap items-center gap-4">
      <form onSubmit={handleSearch} className="flex gap-2">
        <input
          type="text"
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          placeholder="ابحث في المهام..."
          className="border rounded-lg px-3 py-2 w-64"
        />
        <button
          type="submit"
          className="bg-gray-100 px-3 py-2 rounded-lg hover:bg-gray-200 transition"
        >
          بحث
        </button>
      </form>
 
      <select
        value={searchParams.get("status") || "all"}
        onChange={(e) => updateFilter("status", e.target.value)}
        className="border rounded-lg px-3 py-2"
      >
        <option value="all">كل الحالات</option>
        <option value="todo">للتنفيذ</option>
        <option value="in-progress">قيد التنفيذ</option>
        <option value="done">مكتملة</option>
      </select>
 
      <select
        value={searchParams.get("priority") || "all"}
        onChange={(e) => updateFilter("priority", e.target.value)}
        className="border rounded-lg px-3 py-2"
      >
        <option value="all">كل الأولويات</option>
        <option value="low">منخفضة</option>
        <option value="medium">متوسطة</option>
        <option value="high">عالية</option>
      </select>
    </div>
  );
}

الخطوة 12: بناء صفحات الإنشاء والتعديل

أنشئ src/app/tasks/new/page.tsx:

import Link from "next/link";
import { TaskForm } from "../task-form";
 
export default function NewTaskPage() {
  return (
    <div className="max-w-5xl mx-auto px-4 py-8">
      <div className="mb-8">
        <Link href="/tasks" className="text-blue-600 hover:underline">
          &larr; العودة للمهام
        </Link>
        <h1 className="text-3xl font-bold mt-4">إنشاء مهمة جديدة</h1>
      </div>
      <TaskForm />
    </div>
  );
}

أنشئ src/app/tasks/[id]/edit/page.tsx:

import Link from "next/link";
import { notFound } from "next/navigation";
import { getTaskById } from "@/lib/actions/task-queries";
import { TaskForm } from "../../task-form";
 
interface PageProps {
  params: Promise<{ id: string }>;
}
 
export default async function EditTaskPage({ params }: PageProps) {
  const { id } = await params;
  const task = await getTaskById(id);
 
  if (!task) notFound();
 
  return (
    <div className="max-w-5xl mx-auto px-4 py-8">
      <div className="mb-8">
        <Link href="/tasks" className="text-blue-600 hover:underline">
          &larr; العودة للمهام
        </Link>
        <h1 className="text-3xl font-bold mt-4">تعديل المهمة</h1>
      </div>
      <TaskForm task={task} />
    </div>
  );
}

الخطوة 13: أنماط متقدمة

خطوط التجميع (Aggregation Pipelines)

إطار التجميع في MongoDB قوي للتحليلات. إليك كيفية الحصول على معدلات إكمال المهام حسب الأولوية:

export async function getCompletionRatesByPriority() {
  await connectDB();
 
  const results = await Task.aggregate([
    {
      $group: {
        _id: "$priority",
        total: { $sum: 1 },
        completed: {
          $sum: { $cond: [{ $eq: ["$status", "done"] }, 1, 0] },
        },
      },
    },
    {
      $project: {
        priority: "$_id",
        total: 1,
        completed: 1,
        rate: {
          $round: [{ $multiply: [{ $divide: ["$completed", "$total"] }, 100] }, 1],
        },
      },
    },
    { $sort: { rate: -1 } },
  ]);
 
  return results;
}

البرمجيات الوسيطة لـ Mongoose (Hooks)

أضف خطافات pre/post للعمليات الشائعة:

taskSchema.pre("save", function (next) {
  if (this.isModified("status") && this.status === "done") {
    this.set("completedAt", new Date());
  }
  next();
});

الحقول الافتراضية (Virtual Fields)

أضف حقول محسوبة دون تخزينها:

taskSchema.virtual("isOverdue").get(function () {
  if (!this.dueDate || this.status === "done") return false;
  return new Date() > this.dueDate;
});
 
taskSchema.set("toJSON", { virtuals: true });

أفضل ممارسات الإنتاج

1. إدارة حوض الاتصالات

أداة الاتصال التي بنيناها تتعامل مع التجميع، لكن اضبطها للإنتاج:

const opts = {
  bufferCommands: false,
  maxPoolSize: 10,
  serverSelectionTimeoutMS: 5000,
  socketTimeoutMS: 45000,
};

2. إدارة الفهارس

تحقق دائمًا من استخدام الفهارس:

db.tasks.find({ status: "todo" }).explain("executionStats")

3. معالجة الأخطاء

غلّف عمليات قاعدة البيانات بمعالجة أخطاء مناسبة:

export async function createTask(/* ... */): Promise<ActionState> {
  try {
    await connectDB();
    // ... العملية
  } catch (error) {
    if (error instanceof mongoose.Error.ValidationError) {
      return { success: false, message: "بيانات غير صالحة" };
    }
    return { success: false, message: "حدث خطأ غير متوقع" };
  }
}

4. النشر على Vercel

MongoDB Atlas يعمل بشكل ممتاز مع دوال Vercel الخادمية:

npm i -g vercel
vercel
vercel env add MONGODB_URI

استكشاف الأخطاء

"MongoServerError: bad auth"

بيانات اعتماد سلسلة الاتصال خاطئة. تحقق من اسم المستخدم وكلمة المرور في Atlas تحت Database Access.

"MongooseServerSelectionError: connection timed out"

عنوان IP غير مُدرج في القائمة البيضاء. اذهب إلى Atlas Network Access وأضف عنوان IP الحالي.

"OverwriteModelError: Cannot overwrite model"

يحدث أثناء إعادة التحميل الساخن. نمط mongoose.models.Task || في ملف النموذج يمنع هذا.

"buffering timed out after 10000ms"

فشل الاتصال بصمت. تأكد من تعيين MONGODB_URI وأن المجموعة تعمل.


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

الآن بعد أن لديك تطبيق MongoDB + Next.js يعمل، استكشف هذه التحسينات:

  • المصادقة — أضف حسابات المستخدمين مع NextAuth.js
  • التحديثات الفورية — استخدم MongoDB Change Streams مع Server-Sent Events
  • مرفقات الملفات — خزّن الملفات في MongoDB GridFS أو UploadThing
  • البحث النصي المتقدم — ترقية إلى MongoDB Atlas Search للمطابقة الضبابية
  • التخزين المؤقت — أضف تخزين Redis المؤقت

الخلاصة

لقد بنيت تطبيق CRUD متكامل مع MongoDB Atlas و Mongoose و Next.js 15 App Router. يتضمن التطبيق مخططات آمنة الأنواع وServer Actions للتعديلات والتحقق بـ Zod والبحث النصي الكامل والتصفية والتقسيم إلى صفحات وإدارة اتصالات جاهزة للإنتاج.

نموذج المستندات المرن في MongoDB يجعله مثاليًا للنماذج الأولية السريعة والتطبيقات ذات المخططات المتطورة. مع طبقة التحقق في Mongoose وServer Actions في Next.js، تحصل على تجربة تطوير منتجة وآمنة الأنواع مع حد أدنى من الشيفرة المتكررة.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على نشر موقع Google Sites وربط دومين مجاناً 2026 - دليل الاستضافة والنطاق.

ناقش مشروعك معنا

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

دعنا نجد أفضل الحلول لاحتياجاتك.

مقالات ذات صلة

Neon Serverless Postgres مع Next.js App Router: بناء تطبيق كامل مع تفريع قواعد البيانات

تعلّم كيفية بناء تطبيق Next.js كامل مدعوم بقاعدة بيانات Neon Serverless Postgres. يغطي هذا الدليل التطبيقي برنامج التشغيل بدون خادم، تفريع قواعد البيانات لعمليات النشر التجريبية، تجميع الاتصالات، وأنماط الإنتاج الجاهزة.

28 د قراءة·