أشهر ORM لـ TypeScript يلتقي مع حزمة React الحديثة. يمنحك Prisma مخططًا تصريحيًا وأنواعًا مُنشأة تلقائيًا ومحرك استعلامات قويًا — كل ذلك يتكامل بسلاسة مع Next.js 15 Server Components و Server Actions. في هذا الدليل ستبني تطبيقًا كاملاً لإدارة المشاريع من الصفر.
ما ستتعلمه
بنهاية هذا الدليل، ستكون قادرًا على:
- إعداد Prisma ORM مع PostgreSQL في مشروع Next.js 15 App Router
- تعريف النماذج والعلاقات باستخدام Prisma Schema Language
- تشغيل الترحيلات وبذر قاعدة البيانات
- بناء عمليات CRUD كاملة باستخدام Next.js Server Actions
- استخدام Prisma Client لاستعلامات آمنة الأنواع مع التصفية والترقيم
- معالجة التحقق من النماذج باستخدام Zod و
useActionState - النشر في بيئة الإنتاج مع أفضل الممارسات
المتطلبات الأساسية
قبل البدء، تأكد من وجود:
- Node.js 20+ مثبت (
node --version) - خبرة في TypeScript (الأنواع، الواجهات، async/await)
- معرفة بـ Next.js (App Router، Server Components)
- PostgreSQL يعمل محليًا أو مثيل سحابي (سنستخدم Neon)
- محرر أكواد — يُنصح بـ VS Code أو Cursor
لماذا Prisma ORM؟
Prisma هو أكثر ORM انتشارًا لـ TypeScript، تستخدمه شركات مثل Netflix و Notion و Hashicorp. إليك ما يميزه:
| الميزة | Prisma | Drizzle | TypeORM |
|---|---|---|---|
| المخطط | DSL تصريحي (.prisma) | TypeScript | Decorators |
| أمان الأنواع | أنواع مُنشأة تلقائيًا | أنواع مُستنتجة | جزئي |
| الترحيلات | CLI مدمج | drizzle-kit | CLI أو يدوي |
| العلاقات | من الدرجة الأولى، كتابة متداخلة | ربط يدوي | Decorators |
| واجهة الاستعلام | واجهة كائنية بديهية | واجهة شبيهة بـ SQL | نمط Repository |
| Studio | واجهة رسومية مدمجة (Prisma Studio) | Drizzle Studio | لا يوجد |
| دعم Edge | Prisma Accelerate | أصلي | غير محسّن |
يتبع Prisma نهج المخطط أولاً: تعلن نموذج البيانات في ملف .prisma، ويُنشئ Prisma عميلاً مكتملًا مع إكمال تلقائي لكل حقل وعلاقة ومرشح. لا حاجة لتعريف أنواع يدويًا.
ما ستبنيه
تطبيق لإدارة المشاريع يتضمن:
- مشاريع تحتوي على مهام متعددة
- مهام بحالة وأولوية وتواريخ استحقاق
- عمليات CRUD كاملة للمشاريع والمهام
- تصفية وترتيب
- واجهة متجاوبة مع Tailwind CSS
الخطوة 1: إنشاء مشروع Next.js 15
أنشئ مشروعًا جديدًا:
npx create-next-app@latest project-manager --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd project-managerاقبل الإعدادات الافتراضية. هذا ينشئ مشروع Next.js 15 مع App Router و TypeScript و Tailwind CSS.
الخطوة 2: تثبيت Prisma
ثبّت Prisma كاعتماد تطوير و Prisma Client:
npm install prisma --save-dev
npm install @prisma/clientهيّئ Prisma مع PostgreSQL كمزود:
npx prisma init --datasource-provider postgresqlينشئ هذا ملفين:
prisma/schema.prisma— تعريف نموذج البيانات.env— سلسلة اتصال قاعدة البيانات
الخطوة 3: تكوين قاعدة البيانات
افتح .env وعيّن سلسلة اتصال PostgreSQL:
DATABASE_URL="postgresql://user:password@localhost:5432/project_manager?schema=public"إذا كنت تستخدم Neon (يُنصح به للإعداد السريع):
- أنشئ حسابًا مجانيًا على neon.tech
- أنشئ مشروعًا جديدًا
- انسخ سلسلة الاتصال من لوحة التحكم
- الصقها في
.env
إذا كنت تشغّل PostgreSQL محليًا بـ Docker:
docker run --name pg-prisma -e POSTGRES_USER=user -e POSTGRES_PASSWORD=password -e POSTGRES_DB=project_manager -p 5432:5432 -d postgres:16الخطوة 4: تعريف مخطط Prisma
افتح prisma/schema.prisma وعرّف نموذج البيانات:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Project {
id String @id @default(cuid())
name String
description String?
color String @default("#3b82f6")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tasks Task[]
@@map("projects")
}
model Task {
id String @id @default(cuid())
title String
description String?
status TaskStatus @default(TODO)
priority Priority @default(MEDIUM)
dueDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([projectId])
@@index([status])
@@map("tasks")
}
enum TaskStatus {
TODO
IN_PROGRESS
DONE
}
enum Priority {
LOW
MEDIUM
HIGH
URGENT
}النقاط الرئيسية في هذا المخطط:
@id @default(cuid())يُنشئ معرّفات فريدة تلقائيًا@updatedAtيُحدّث الطابع الزمني تلقائيًا عند كل تغييرTask[]على Project يُعرّف علاقة واحد-للعديدonDelete: Cascadeيحذف جميع المهام عند حذف المشروع@@indexيُنشئ فهارس قاعدة البيانات لاستعلامات أسرع@@mapيربط النموذج باسم جدول محدد- Enums توفر قيم حالة وأولوية آمنة الأنواع
الخطوة 5: تشغيل أول ترحيل
أنشئ وطبّق الترحيل:
npx prisma migrate dev --name initيقوم هذا الأمر بثلاثة أشياء:
- ينشئ ملف ترحيل SQL في
prisma/migrations/ - يطبّق الترحيل على قاعدة البيانات
- يُنشئ Prisma Client مع أنواع TypeScript كاملة
يمكنك فحص SQL المُنشأ في prisma/migrations/[timestamp]_init/migration.sql.
الخطوة 6: إنشاء Prisma Client Singleton
في بيئة Next.js، يمكن أن يُنشئ إعادة التحميل الساخن عدة مثيلات من Prisma Client. أنشئ singleton لمنع ذلك.
أنشئ src/lib/prisma.ts:
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}هذا يضمن وجود مثيل واحد فقط من PrismaClient أثناء التطوير.
الخطوة 7: بذر قاعدة البيانات
أنشئ prisma/seed.ts لملء قاعدة البيانات ببيانات تجريبية:
import { PrismaClient, TaskStatus, Priority } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
// تنظيف البيانات الحالية
await prisma.task.deleteMany();
await prisma.project.deleteMany();
// إنشاء مشاريع مع مهام
const webApp = await prisma.project.create({
data: {
name: "إعادة تصميم تطبيق الويب",
description: "تجديد شامل لموقع الشركة",
color: "#3b82f6",
tasks: {
create: [
{
title: "تصميم صفحة هبوط جديدة",
description: "إنشاء إطارات سلكية ونماذج للصفحة الرئيسية",
status: TaskStatus.DONE,
priority: Priority.HIGH,
dueDate: new Date("2026-04-15"),
},
{
title: "تنفيذ المصادقة",
description: "إعداد تسجيل الدخول والتسجيل وإعادة تعيين كلمة المرور",
status: TaskStatus.IN_PROGRESS,
priority: Priority.URGENT,
dueDate: new Date("2026-04-20"),
},
{
title: "بناء لوحة التحكم",
description: "إنشاء لوحة تحكم المستخدم الرئيسية مع الرسوم البيانية",
status: TaskStatus.TODO,
priority: Priority.MEDIUM,
dueDate: new Date("2026-05-01"),
},
{
title: "كتابة وثائق API",
status: TaskStatus.TODO,
priority: Priority.LOW,
},
],
},
},
});
const mobileApp = await prisma.project.create({
data: {
name: "النموذج الأولي لتطبيق الجوال",
description: "تطبيق React Native لـ iOS و Android",
color: "#10b981",
tasks: {
create: [
{
title: "إعداد مشروع Expo",
status: TaskStatus.DONE,
priority: Priority.HIGH,
},
{
title: "بناء هيكل التنقل",
status: TaskStatus.IN_PROGRESS,
priority: Priority.HIGH,
dueDate: new Date("2026-04-10"),
},
{
title: "دمج الإشعارات الفورية",
status: TaskStatus.TODO,
priority: Priority.MEDIUM,
dueDate: new Date("2026-05-15"),
},
],
},
},
});
console.log("تم بذر المشاريع:", { webApp: webApp.id, mobileApp: mobileApp.id });
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});أضف أمر البذر إلى package.json:
{
"prisma": {
"seed": "npx tsx prisma/seed.ts"
}
}شغّل البذر:
npx prisma db seedيمكنك التحقق من البيانات باستخدام Prisma Studio:
npx prisma studioيفتح هذا متصفح قاعدة بيانات مرئي على http://localhost:5555.
الخطوة 8: بناء تعريفات الأنواع والتحقق
أنشئ src/lib/validations.ts لمخططات Zod المشتركة:
import { z } from "zod";
export const createProjectSchema = z.object({
name: z.string().min(1, "الاسم مطلوب").max(100),
description: z.string().max(500).optional(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/, "صيغة لون غير صالحة"),
});
export const createTaskSchema = z.object({
title: z.string().min(1, "العنوان مطلوب").max(200),
description: z.string().max(1000).optional(),
status: z.enum(["TODO", "IN_PROGRESS", "DONE"]).default("TODO"),
priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"),
dueDate: z.string().optional(),
projectId: z.string().min(1, "المشروع مطلوب"),
});
export const updateTaskSchema = createTaskSchema.partial().extend({
id: z.string().min(1),
});
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type CreateTaskInput = z.infer<typeof createTaskSchema>;
export type UpdateTaskInput = z.infer<typeof updateTaskSchema>;الخطوة 9: إنشاء Server Actions
Server Actions هي الطريقة الموصى بها للتعامل مع التغييرات في Next.js App Router. أنشئ src/app/actions.ts:
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import {
createProjectSchema,
createTaskSchema,
updateTaskSchema,
} from "@/lib/validations";
// ─── إجراءات المشاريع ─────────────────────────────────
export async function createProject(formData: FormData) {
const raw = {
name: formData.get("name") as string,
description: formData.get("description") as string,
color: formData.get("color") as string,
};
const validated = createProjectSchema.safeParse(raw);
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors };
}
await prisma.project.create({
data: validated.data,
});
revalidatePath("/");
return { success: true };
}
export async function deleteProject(id: string) {
await prisma.project.delete({
where: { id },
});
revalidatePath("/");
return { success: true };
}
// ─── إجراءات المهام ─────────────────────────────────────
export async function createTask(formData: FormData) {
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,
projectId: formData.get("projectId") as string,
};
const validated = createTaskSchema.safeParse(raw);
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors };
}
const { dueDate, ...rest } = validated.data;
await prisma.task.create({
data: {
...rest,
dueDate: dueDate ? new Date(dueDate) : null,
},
});
revalidatePath("/");
return { success: true };
}
export async function updateTaskStatus(id: string, status: string) {
const validated = updateTaskSchema.safeParse({ id, status });
if (!validated.success) {
return { error: "مدخل غير صالح" };
}
await prisma.task.update({
where: { id },
data: { status: validated.data.status },
});
revalidatePath("/");
return { success: true };
}
export async function deleteTask(id: string) {
await prisma.task.delete({
where: { id },
});
revalidatePath("/");
return { success: true };
}الأنماط الرئيسية هنا:
"use server"يحدد الملف كمحتوٍ على Server Actions- التحقق بـ Zod يضمن سلامة البيانات قبل أي عملية على قاعدة البيانات
revalidatePath("/")يمسح الذاكرة المؤقتة لتحديث الواجهة فورًا- قيم إرجاع مُحددة الأنواع تتيح للعميل معالجة حالات النجاح والخطأ
الخطوة 10: بناء صفحة قائمة المشاريع
أنشئ الصفحة الرئيسية في src/app/page.tsx:
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { deleteProject } from "./actions";
export default async function HomePage() {
const projects = await prisma.project.findMany({
include: {
_count: {
select: { tasks: true },
},
tasks: {
select: { status: true },
},
},
orderBy: { createdAt: "desc" },
});
return (
<main className="max-w-4xl mx-auto p-8">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold">المشاريع</h1>
<Link
href="/projects/new"
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
>
مشروع جديد
</Link>
</div>
<div className="grid gap-4">
{projects.map((project) => {
const done = project.tasks.filter((t) => t.status === "DONE").length;
const total = project._count.tasks;
const progress = total > 0 ? Math.round((done / total) * 100) : 0;
return (
<Link
key={project.id}
href={`/projects/${project.id}`}
className="block border rounded-lg p-6 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: project.color }}
/>
<div>
<h2 className="text-xl font-semibold">{project.name}</h2>
{project.description && (
<p className="text-gray-500 mt-1">
{project.description}
</p>
)}
</div>
</div>
<span className="text-sm text-gray-400">
{total} مهمة
</span>
</div>
{total > 0 && (
<div className="mt-4">
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-500">التقدم</span>
<span className="font-medium">{progress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="h-2 rounded-full transition-all"
style={{
width: `${progress}%`,
backgroundColor: project.color,
}}
/>
</div>
</div>
)}
</Link>
);
})}
</div>
{projects.length === 0 && (
<div className="text-center py-12 text-gray-500">
<p className="text-lg">لا توجد مشاريع بعد</p>
<p className="mt-2">أنشئ مشروعك الأول للبدء.</p>
</div>
)}
</main>
);
}لاحظ كيف تعمل استعلامات Prisma مباشرة داخل Server Component — لا حاجة لمسارات API. خيار include يتيح لك جلب البيانات المرتبطة والتجميعات في استعلام واحد.
الخطوة 11: بناء صفحة تفاصيل المشروع
أنشئ src/app/projects/[id]/page.tsx:
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { TaskList } from "./task-list";
import { TaskForm } from "./task-form";
interface Props {
params: Promise<{ id: string }>;
}
export default async function ProjectPage({ params }: Props) {
const { id } = await params;
const project = await prisma.project.findUnique({
where: { id },
include: {
tasks: {
orderBy: [
{ status: "asc" },
{ priority: "desc" },
{ createdAt: "desc" },
],
},
},
});
if (!project) {
notFound();
}
const tasksByStatus = {
TODO: project.tasks.filter((t) => t.status === "TODO"),
IN_PROGRESS: project.tasks.filter((t) => t.status === "IN_PROGRESS"),
DONE: project.tasks.filter((t) => t.status === "DONE"),
};
return (
<main className="max-w-5xl mx-auto p-8">
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div
className="w-5 h-5 rounded-full"
style={{ backgroundColor: project.color }}
/>
<h1 className="text-3xl font-bold">{project.name}</h1>
</div>
{project.description && (
<p className="text-gray-500 text-lg">{project.description}</p>
)}
</div>
<TaskForm projectId={project.id} />
<div className="grid md:grid-cols-3 gap-6 mt-8">
<TaskColumn title="قيد الانتظار" tasks={tasksByStatus.TODO} color="#6b7280" />
<TaskColumn
title="قيد التنفيذ"
tasks={tasksByStatus.IN_PROGRESS}
color="#f59e0b"
/>
<TaskColumn title="مكتمل" tasks={tasksByStatus.DONE} color="#10b981" />
</div>
</main>
);
}
function TaskColumn({
title,
tasks,
color,
}: {
title: string;
tasks: any[];
color: string;
}) {
return (
<div>
<div className="flex items-center gap-2 mb-4">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: color }}
/>
<h2 className="font-semibold text-lg">{title}</h2>
<span className="text-sm text-gray-400 ml-auto">{tasks.length}</span>
</div>
<TaskList tasks={tasks} />
</div>
);
}الخطوة 12: بناء مكونات المهام
أنشئ مكون قائمة المهام في src/app/projects/[id]/task-list.tsx:
"use client";
import { updateTaskStatus, deleteTask } from "@/app/actions";
import { useTransition } from "react";
interface Task {
id: string;
title: string;
description: string | null;
status: string;
priority: string;
dueDate: string | null;
}
const priorityColors: Record<string, string> = {
LOW: "bg-gray-100 text-gray-600",
MEDIUM: "bg-blue-100 text-blue-600",
HIGH: "bg-orange-100 text-orange-600",
URGENT: "bg-red-100 text-red-600",
};
export function TaskList({ tasks }: { tasks: Task[] }) {
return (
<div className="space-y-3">
{tasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
{tasks.length === 0 && (
<p className="text-gray-400 text-sm text-center py-4">لا توجد مهام</p>
)}
</div>
);
}
function TaskCard({ task }: { task: Task }) {
const [isPending, startTransition] = useTransition();
const handleStatusChange = (newStatus: string) => {
startTransition(() => {
updateTaskStatus(task.id, newStatus);
});
};
const handleDelete = () => {
startTransition(() => {
deleteTask(task.id);
});
};
return (
<div
className={`border rounded-lg p-4 bg-white ${
isPending ? "opacity-50" : ""
}`}
>
<div className="flex items-start justify-between">
<h3 className="font-medium">{task.title}</h3>
<button
onClick={handleDelete}
className="text-gray-400 hover:text-red-500 text-sm"
>
×
</button>
</div>
{task.description && (
<p className="text-gray-500 text-sm mt-1">{task.description}</p>
)}
<div className="flex items-center gap-2 mt-3">
<span
className={`text-xs px-2 py-1 rounded-full ${
priorityColors[task.priority]
}`}
>
{task.priority}
</span>
{task.dueDate && (
<span className="text-xs text-gray-400">
الاستحقاق {new Date(task.dueDate).toLocaleDateString("ar")}
</span>
)}
</div>
<div className="flex gap-1 mt-3">
{["TODO", "IN_PROGRESS", "DONE"].map((status) => (
<button
key={status}
onClick={() => handleStatusChange(status)}
disabled={task.status === status}
className={`text-xs px-2 py-1 rounded ${
task.status === status
? "bg-gray-900 text-white"
: "bg-gray-100 hover:bg-gray-200 text-gray-600"
}`}
>
{status.replace("_", " ")}
</button>
))}
</div>
</div>
);
}أنشئ نموذج المهام في src/app/projects/[id]/task-form.tsx:
"use client";
import { createTask } from "@/app/actions";
import { useRef } from "react";
import { useActionState } from "react";
const initialState = { error: null as any, success: false };
export function TaskForm({ projectId }: { projectId: string }) {
const formRef = useRef<HTMLFormElement>(null);
async function action(_prev: typeof initialState, formData: FormData) {
formData.set("projectId", projectId);
const result = await createTask(formData);
if (result.success) {
formRef.current?.reset();
}
return result as typeof initialState;
}
const [state, formAction, isPending] = useActionState(action, initialState);
return (
<form ref={formRef} action={formAction} className="border rounded-lg p-4">
<h3 className="font-semibold mb-3">إضافة مهمة جديدة</h3>
<div className="grid md:grid-cols-2 gap-3">
<input
name="title"
placeholder="عنوان المهمة"
required
className="border rounded px-3 py-2"
/>
<select name="priority" className="border rounded px-3 py-2">
<option value="LOW">أولوية منخفضة</option>
<option value="MEDIUM" selected>أولوية متوسطة</option>
<option value="HIGH">أولوية عالية</option>
<option value="URGENT">عاجل</option>
</select>
<input
name="description"
placeholder="الوصف (اختياري)"
className="border rounded px-3 py-2"
/>
<input
name="dueDate"
type="date"
className="border rounded px-3 py-2"
/>
</div>
<input type="hidden" name="status" value="TODO" />
{state.error && (
<p className="text-red-500 text-sm mt-2">
{Object.values(state.error).flat().join("، ")}
</p>
)}
<button
type="submit"
disabled={isPending}
className="mt-3 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? "جاري الإضافة..." : "إضافة مهمة"}
</button>
</form>
);
}الخطوة 13: استعلامات Prisma المتقدمة
يتفوق Prisma في الاستعلامات المعقدة. إليك أنماطًا ستستخدمها بشكل متكرر:
التصفية والترقيم
// جلب المهام مع مرشحات
const tasks = await prisma.task.findMany({
where: {
projectId: "some-id",
status: "IN_PROGRESS",
priority: { in: ["HIGH", "URGENT"] },
dueDate: { lte: new Date() }, // المهام المتأخرة
},
orderBy: { priority: "desc" },
skip: 0,
take: 20,
});التجميعات
// عدّ المهام حسب الحالة لكل مشروع
const stats = await prisma.task.groupBy({
by: ["projectId", "status"],
_count: { id: true },
});الكتابة المتداخلة (المعاملات)
// إنشاء مشروع مع مهام في معاملة واحدة
const project = await prisma.project.create({
data: {
name: "مشروع جديد",
tasks: {
createMany: {
data: [
{ title: "المهمة 1", priority: "HIGH" },
{ title: "المهمة 2", priority: "MEDIUM" },
],
},
},
},
include: { tasks: true },
});المعاملات التفاعلية
// نقل جميع المهام من مشروع إلى آخر
await prisma.$transaction(async (tx) => {
const tasks = await tx.task.findMany({
where: { projectId: sourceId },
});
await tx.task.updateMany({
where: { projectId: sourceId },
data: { projectId: targetId },
});
await tx.project.delete({
where: { id: sourceId },
});
return tasks.length;
});الخطوة 14: إضافة البحث والتصفية
أنشئ مكون البحث في src/app/search.tsx:
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback } from "react";
export function SearchBar() {
const router = useRouter();
const searchParams = useSearchParams();
const updateFilter = useCallback(
(key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
router.push(`/?${params.toString()}`);
},
[router, searchParams]
);
return (
<div className="flex gap-3 mb-6">
<input
type="search"
placeholder="البحث في المشاريع..."
defaultValue={searchParams.get("q") ?? ""}
onChange={(e) => updateFilter("q", e.target.value)}
className="border rounded px-3 py-2 flex-1"
/>
<select
defaultValue={searchParams.get("sort") ?? "newest"}
onChange={(e) => updateFilter("sort", e.target.value)}
className="border rounded px-3 py-2"
>
<option value="newest">الأحدث أولاً</option>
<option value="oldest">الأقدم أولاً</option>
<option value="name">حسب الاسم</option>
</select>
</div>
);
}حدّث الصفحة الرئيسية لاستخدام معاملات البحث:
interface Props {
searchParams: Promise<{ q?: string; sort?: string }>;
}
export default async function HomePage({ searchParams }: Props) {
const { q, sort } = await searchParams;
const projects = await prisma.project.findMany({
where: q
? {
OR: [
{ name: { contains: q, mode: "insensitive" } },
{ description: { contains: q, mode: "insensitive" } },
],
}
: undefined,
include: {
_count: { select: { tasks: true } },
tasks: { select: { status: true } },
},
orderBy:
sort === "name"
? { name: "asc" }
: sort === "oldest"
? { createdAt: "asc" }
: { createdAt: "desc" },
});
// ... بقية المكون
}الخطوة 15: أفضل ممارسات الإنتاج
تجميع الاتصالات
لبيئات serverless، كوّن تجميع الاتصالات في المخطط:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}عيّن DATABASE_URL لسلسلة اتصال مُجمّعة (PgBouncer أو Neon pooler أو Prisma Accelerate) و DIRECT_URL للاتصال المباشر (يُستخدم فقط للترحيلات).
Prisma Accelerate
للنشر على الحافة، فعّل Prisma Accelerate:
npm install @prisma/extension-accelerateحدّث العميل:
import { PrismaClient } from "@prisma/client";
import { withAccelerate } from "@prisma/extension-accelerate";
export const prisma = new PrismaClient().$extends(withAccelerate());هذا يُمكّن التخزين المؤقت العالمي وتجميع الاتصالات على الحافة.
التسجيل
فعّل تسجيل الاستعلامات في التطوير:
export const prisma = new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
});استكشاف الأخطاء وإصلاحها
"PrismaClientInitializationError: Can't reach database server"
- تحقق من
DATABASE_URLفي.env - تأكد أن PostgreSQL يعمل
- تحقق من الوصول للشبكة (جدار الحماية، VPN)
الأنواع لا تتحدث بعد تغييرات المخطط
شغّل إنشاء العميل يدويًا:
npx prisma generateهذا يُعيد إنشاء أنواع TypeScript من المخطط.
"Unique constraint violation"
بياناتك تحتوي على قيم مكررة في حقل فريد. إما حدّث السجل المتعارض أو استخدم upsert:
await prisma.project.upsert({
where: { id: "existing-id" },
update: { name: "اسم محدّث" },
create: { name: "مشروع جديد" },
});انحراف الترحيل في الإنتاج
إذا انحرف مخطط قاعدة البيانات عن الترحيلات:
npx prisma migrate resolve --applied "migration_name"أو إعادة تعيين (التطوير فقط):
npx prisma migrate resetالخطوات التالية
الآن بعد أن لديك تطبيق متكامل يعمل مع Prisma و Next.js، استكشف هذه المواضيع:
- إضافة المصادقة مع NextAuth.js أو Better Auth لتحديد المشاريع لكل مستخدم
- تنفيذ التحديثات الفورية مع Prisma Pulse للوحات المهام الحية
- إضافة رفع الملفات لمرفقات المهام باستخدام Vercel Blob أو S3
- إعداد CI/CD مع GitHub Actions لتشغيل الترحيلات تلقائيًا
- استكشاف إضافات Prisma Client للـ middleware والحذف الناعم وتسجيل المراجعة
الخلاصة
في هذا الدليل، بنيت تطبيقًا كاملاً لإدارة المشاريع باستخدام Prisma ORM و Next.js 15 App Router. تعلمت كيف:
- تعريف نموذج بيانات بـ Prisma Schema Language مع العلاقات والتعدادات
- تشغيل الترحيلات وبذر قاعدة البيانات
- إنشاء Prisma Client singleton لـ Next.js
- بناء Server Actions آمنة الأنواع لجميع عمليات CRUD
- استخدام واجهة استعلامات Prisma للتصفية والترقيم والتجميعات
- التعامل مع الكتابة المتداخلة والمعاملات للعمليات المعقدة
- تنفيذ البحث والتصفية مع معاملات بحث URL
- تكوين نشر الإنتاج مع تجميع الاتصالات ودعم الحافة
يزيل مخطط Prisma التصريحي والأنواع المُنشأة تلقائيًا فئة كاملة من الأخطاء — لا يمكنك الاستعلام عن حقل غير موجود أو تمرير نوع خاطئ أو نسيان علاقة مطلوبة. مع Next.js Server Components و Server Actions، تحصل على تجربة تطوير متكاملة آمنة الأنواع من قاعدة البيانات إلى واجهة المستخدم.