قسمت هفدهم - CRUD پروژهها با Next.js و Prisma
بخش مدیریت پروژهها را با Next.js، Prisma و Route Handlers میسازیم.
CRUD پروژهها با Next.js و Prisma
در قسمت قبل، مسیر اصلی اتصال فرمها و UI به بکاند را مشخص کردیم. دیدیم که در Next.js هم میتوانیم از Server Actions استفاده کنیم و هم از Route Handlers، اما برای ساخت یک ساختار قابل توسعهتر، مخصوصاً برای CRUD، داشبورد و CMS، مسیر اصلی این دوره را روی Route Handlers قرار دادیم.
در این قسمت وارد پیادهسازی واقعی میشویم و بخش مدیریت پروژهها را با Next.js، Prisma و Route Handlers میسازیم.
هدف این قسمت این است که برای پروژهها بتوانیم این عملیات را انجام دهیم:
- ساخت پروژه جدید
- نمایش لیست پروژهها
- ویرایش پروژه
- حذف پروژه
- مدیریت وضعیت انتشار
این دقیقاً همان چیزی است که به آن CRUD میگوییم.
Create -> ساخت داده
Read -> خواندن داده
Update -> ویرایش داده
Delete -> حذف داده
در این آموزش، این عملیات را با endpointهای زیر پیادهسازی میکنیم:
GET /api/projects
POST /api/projects
GET /api/projects/[id]
PATCH /api/projects/[id]
DELETE /api/projects/[id]
نیازهای مدیریت پروژه
قبل از نوشتن API، باید مشخص کنیم یک پروژه در سایت ما چه دادههایی دارد.
برای یک پرتفولیو، معمولاً هر پروژه شامل این اطلاعات است:
- عنوان پروژه
- آدرس یکتا یا
slug - توضیح کوتاه
- توضیح کامل
- تصویر اصلی
- لینک دمو
- لینک گیتهاب
- تکنولوژیهای استفادهشده
- وضعیت انتشار
- تاریخ ایجاد
- تاریخ آخرین ویرایش
پس مدل Project در دیتابیس میتواند چیزی شبیه این باشد:
model Project {
id String @id @default(cuid())
title String
slug String @unique
summary String
content String?
imageUrl String?
demoUrl String?
githubUrl String?
technologies String[]
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
اگر از PostgreSQL استفاده میکنیم، فیلد زیر قابل استفاده است:
technologies String[]
چون PostgreSQL از array پشتیبانی میکند.
بعد از اضافه کردن مدل به فایل schema.prisma، باید migration بسازیم:
npx prisma migrate dev --name add_project_model
و اگر هنوز Prisma Client ساخته نشده بود:
npx prisma generate
برای استفاده راحتتر از Prisma در پروژه، معمولاً یک فایل جدا برای client میسازیم.
// 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
}
این الگو کمک میکند در حالت development، به خاطر hot reload، چندین اتصال غیرضروری به دیتابیس ساخته نشود.
ساخت پروژه جدید
برای ساخت پروژه جدید، از متد POST استفاده میکنیم.
مسیر فایل:
app/api/projects/route.ts
اما قبل از نوشتن endpoint، بهتر است schema اعتبارسنجی پروژه را بسازیم. چون هیچ دادهای نباید بدون بررسی وارد دیتابیس شود.
// lib/validations/project.ts
import { z } from 'zod'
export const createProjectSchema = z.object({
title: z
.string()
.trim()
.min(2, 'عنوان پروژه باید حداقل 2 کاراکتر باشد.'),
slug: z
.string()
.trim()
.min(2, 'اسلاگ پروژه الزامی است.')
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'فرمت اسلاگ معتبر نیست.'),
summary: z
.string()
.trim()
.min(10, 'توضیح کوتاه باید حداقل 10 کاراکتر باشد.'),
content: z
.string()
.trim()
.optional(),
imageUrl: z
.url('آدرس تصویر معتبر نیست.')
.optional()
.or(z.literal('')),
demoUrl: z
.url('لینک دمو معتبر نیست.')
.optional()
.or(z.literal('')),
githubUrl: z
.url('لینک گیتهاب معتبر نیست.')
.optional()
.or(z.literal('')),
technologies: z
.array(z.string().trim().min(1))
.default([]),
published: z
.boolean()
.default(false),
})
حالا میتوانیم endpoint ایجاد پروژه را بنویسیم:
// app/api/projects/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { createProjectSchema } from '@/lib/validations/project'
export async function POST(request: Request) {
try {
const body = await request.json()
const result = createProjectSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{
success: false,
errors: result.error.flatten().fieldErrors,
},
{ status: 400 }
)
}
const project = await prisma.project.create({
data: {
title: result.data.title,
slug: result.data.slug,
summary: result.data.summary,
content: result.data.content || null,
imageUrl: result.data.imageUrl || null,
demoUrl: result.data.demoUrl || null,
githubUrl: result.data.githubUrl || null,
technologies: result.data.technologies,
published: result.data.published,
},
})
return NextResponse.json(
{
success: true,
data: project,
},
{ status: 201 }
)
} catch {
return NextResponse.json(
{
success: false,
error: 'Internal server error',
},
{ status: 500 }
)
}
}
در این endpoint چند کار مهم انجام میشود:
- داده از body درخواست خوانده میشود
- داده با
Zodاعتبارسنجی میشود - اگر داده نامعتبر باشد، پاسخ
400برمیگردد - اگر داده معتبر باشد، با
Prismaدر دیتابیس ذخیره میشود - در صورت موفقیت، پاسخ
201 Createdبرمیگردد
برای ارسال داده از سمت فرم، میتوانیم از fetch استفاده کنیم:
'use client'
import { FormEvent, useState } from 'react'
export function CreateProjectForm() {
const [pending, setPending] = useState(false)
const [message, setMessage] = useState('')
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setPending(true)
setMessage('')
const formData = new FormData(event.currentTarget)
const payload = {
title: String(formData.get('title') ?? ''),
slug: String(formData.get('slug') ?? ''),
summary: String(formData.get('summary') ?? ''),
content: String(formData.get('content') ?? ''),
imageUrl: String(formData.get('imageUrl') ?? ''),
demoUrl: String(formData.get('demoUrl') ?? ''),
githubUrl: String(formData.get('githubUrl') ?? ''),
technologies: String(formData.get('technologies') ?? '')
.split(',')
.map((item) => item.trim())
.filter(Boolean),
published: formData.get('published') === 'on',
}
const response = await fetch('/api/projects', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
const result = await response.json()
setPending(false)
if (!response.ok) {
setMessage(result.error ?? 'Project could not be created')
return
}
setMessage('Project created successfully')
event.currentTarget.reset()
}
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Title" />
<input name="slug" placeholder="Slug" />
<textarea name="summary" placeholder="Summary" />
<textarea name="content" placeholder="Content" />
<input name="imageUrl" placeholder="Image URL" />
<input name="demoUrl" placeholder="Demo URL" />
<input name="githubUrl" placeholder="GitHub URL" />
<input name="technologies" placeholder="Next.js, Prisma, Tailwind CSS" />
<label>
<input type="checkbox" name="published" />
Published
</label>
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Project'}
</button>
{message && <p>{message}</p>}
</form>
)
}
این فرم ساده است، اما مسیر اصلی را نشان میدهد:
Form -> fetch -> Route Handler -> Zod -> Prisma -> Database
نمایش لیست پروژهها
برای نمایش لیست پروژهها، از متد GET در همین مسیر استفاده میکنیم:
GET /api/projects
در فایل app/api/projects/route.ts کنار متد POST، متد GET را هم اضافه میکنیم:
// app/api/projects/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { createProjectSchema } from '@/lib/validations/project'
export async function GET() {
try {
const projects = await prisma.project.findMany({
orderBy: {
createdAt: 'desc',
},
})
return NextResponse.json({
success: true,
data: projects,
})
} catch {
return NextResponse.json(
{
success: false,
error: 'Internal server error',
},
{ status: 500 }
)
}
}
export async function POST(request: Request) {
// همان کد ساخت پروژه جدید
}
حالا هر وقت درخواست GET به /api/projects ارسال شود، لیست پروژهها از دیتابیس خوانده میشود.
اگر بخواهیم فقط پروژههای منتشرشده را نمایش دهیم، میتوانیم از where استفاده کنیم:
const projects = await prisma.project.findMany({
where: {
published: true,
},
orderBy: {
createdAt: 'desc',
},
})
اما در داشبورد معمولاً باید همه پروژهها را ببینیم؛ چه منتشر شده باشند، چه منتشر نشده باشند. برای صفحه عمومی سایت، معمولاً فقط پروژههای منتشرشده را نمایش میدهیم.
یک نمونه دریافت لیست در Client Component:
'use client'
import { useEffect, useState } from 'react'
type Project = {
id: string
title: string
slug: string
summary: string
published: boolean
}
export function ProjectsList() {
const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
async function loadProjects() {
const response = await fetch('/api/projects')
const result = await response.json()
if (response.ok) {
setProjects(result.data)
}
setLoading(false)
}
loadProjects()
}, [])
if (loading) {
return <p>Loading projects...</p>
}
return (
<div>
{projects.map((project) => (
<article key={project.id}>
<h2>{project.title}</h2>
<p>{project.summary}</p>
<span>{project.published ? 'Published' : 'Draft'}</span>
</article>
))}
</div>
)
}
البته اگر در Server Component هستیم، بهتر است داده را مستقیم با Prisma بخوانیم، نه اینکه از سرور به API داخلی خودمان fetch بزنیم.
import { prisma } from '@/lib/prisma'
export default async function ProjectsPage() {
const projects = await prisma.project.findMany({
where: {
published: true,
},
orderBy: {
createdAt: 'desc',
},
})
return (
<main>
{projects.map((project) => (
<article key={project.id}>
<h2>{project.title}</h2>
<p>{project.summary}</p>
</article>
))}
</main>
)
}
پس قانون کلی این است:
- در
Client ComponentازfetchبهRoute Handlerاستفاده میکنیم - در
Server Componentداده را مستقیم از دیتابیس میخوانیم
ویرایش پروژه
برای ویرایش پروژه، به مسیر داینامیک نیاز داریم:
app/api/projects/[id]/route.ts
این مسیر برای عملیات مربوط به یک پروژه خاص استفاده میشود:
GET /api/projects/[id]
PATCH /api/projects/[id]
DELETE /api/projects/[id]
برای ویرایش، از متد PATCH استفاده میکنیم، چون قرار است بخشی از اطلاعات پروژه تغییر کند.
اول schema ویرایش را میسازیم. تفاوتش با schema ساخت پروژه این است که در ویرایش، معمولاً فیلدها اختیاری هستند.
// lib/validations/project.ts
export const updateProjectSchema = createProjectSchema.partial()
حالا فایل route مربوط به یک پروژه را میسازیم:
// app/api/projects/[id]/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { updateProjectSchema } from '@/lib/validations/project'
export async function PATCH(
request: Request,
context: RouteContext<'/api/projects/[id]'>
) {
try {
const { id } = await context.params
const body = await request.json()
const result = updateProjectSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{
success: false,
errors: result.error.flatten().fieldErrors,
},
{ status: 400 }
)
}
const project = await prisma.project.update({
where: {
id,
},
data: result.data,
})
return NextResponse.json({
success: true,
data: project,
})
} catch {
return NextResponse.json(
{
success: false,
error: 'Internal server error',
},
{ status: 500 }
)
}
}
در این endpoint:
idازparamsخوانده میشود- body درخواست خوانده میشود
- داده با
updateProjectSchemaاعتبارسنجی میشود - پروژه با
prisma.project.update()ویرایش میشود
اما این کد هنوز یک مشکل دارد: اگر پروژهای با این id وجود نداشته باشد، Prisma خطا میدهد و ما فقط 500 برمیگردانیم. بهتر است قبل از ویرایش، وجود پروژه را بررسی کنیم.
const existingProject = await prisma.project.findUnique({
where: {
id,
},
})
if (!existingProject) {
return NextResponse.json(
{
success: false,
error: 'Project not found',
},
{ status: 404 }
)
}
نسخه بهتر PATCH:
export async function PATCH(
request: Request,
context: RouteContext<'/api/projects/[id]'>
) {
try {
const { id } = await context.params
const body = await request.json()
const result = updateProjectSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{
success: false,
errors: result.error.flatten().fieldErrors,
},
{ status: 400 }
)
}
const existingProject = await prisma.project.findUnique({
where: {
id,
},
})
if (!existingProject) {
return NextResponse.json(
{
success: false,
error: 'Project not found',
},
{ status: 404 }
)
}
const project = await prisma.project.update({
where: {
id,
},
data: result.data,
})
return NextResponse.json({
success: true,
data: project,
})
} catch {
return NextResponse.json(
{
success: false,
error: 'Internal server error',
},
{ status: 500 }
)
}
}
برای ارسال درخواست ویرایش از سمت client:
async function updateProject(id: string, payload: unknown) {
const response = await fetch(`/api/projects/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.error ?? 'Project could not be updated')
}
return result.data
}
حذف پروژه
برای حذف پروژه، از متد DELETE در همان مسیر داینامیک استفاده میکنیم:
DELETE /api/projects/[id]
کد:
// app/api/projects/[id]/route.ts
export async function DELETE(
_request: Request,
context: RouteContext<'/api/projects/[id]'>
) {
try {
const { id } = await context.params
const existingProject = await prisma.project.findUnique({
where: {
id,
},
})
if (!existingProject) {
return NextResponse.json(
{
success: false,
error: 'Project not found',
},
{ status: 404 }
)
}
await prisma.project.delete({
where: {
id,
},
})
return NextResponse.json({
success: true,
message: 'Project deleted successfully',
})
} catch {
return NextResponse.json(
{
success: false,
error: 'Internal server error',
},
{ status: 500 }
)
}
}
در حذف داده باید دقت بیشتری داشته باشیم، چون این عملیات برگشتپذیر نیست، مگر اینکه از soft delete استفاده کنیم.
در پروژههای ساده میتوانیم واقعاً رکورد را حذف کنیم. اما در پروژههای جدیتر، گاهی بهتر است بهجای حذف کامل، فیلدی مثل deletedAt داشته باشیم.
مثلاً:
deletedAt DateTime?
بعد بهجای delete، مقدار deletedAt را پر میکنیم.
اما برای این دوره، حذف واقعی با prisma.project.delete() کافی است.
نمونه درخواست حذف از سمت client:
async function deleteProject(id: string) {
const response = await fetch(`/api/projects/${id}`, {
method: 'DELETE',
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.error ?? 'Project could not be deleted')
}
return result
}
در UI بهتر است قبل از حذف، از کاربر تأیید بگیریم:
<button
type="button"
onClick={() => {
const confirmed = window.confirm('Delete this project?')
if (confirmed) {
deleteProject(project.id)
}
}}
>
Delete
</button>
مدیریت انتشار
یکی از نیازهای مهم در داشبورد، مدیریت وضعیت انتشار است.
گاهی پروژه را ساختهایم، اما هنوز نمیخواهیم در سایت عمومی نمایش داده شود. برای همین در مدل Project فیلد published داریم:
published Boolean @default(false)
وقتی مقدار published برابر false باشد، پروژه در حالت پیشنویس است. وقتی true باشد، پروژه منتشر شده است.
برای تغییر وضعیت انتشار، میتوانیم از همان endpoint ویرایش استفاده کنیم:
PATCH /api/projects/[id]
و فقط مقدار published را بفرستیم:
await fetch(`/api/projects/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
published: true,
}),
})
یا برای برگرداندن به حالت پیشنویس:
await fetch(`/api/projects/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
published: false,
}),
})
در داشبورد میتوانیم وضعیت را اینطور نمایش دهیم:
<span>
{project.published ? 'Published' : 'Draft'}
</span>
و یک دکمه برای تغییر وضعیت داشته باشیم:
<button
type="button"
onClick={() => {
updateProject(project.id, {
published: !project.published,
})
}}
>
{project.published ? 'Move to Draft' : 'Publish'}
</button>
اما در صفحه عمومی سایت، باید فقط پروژههای منتشرشده را نمایش دهیم:
const projects = await prisma.project.findMany({
where: {
published: true,
},
orderBy: {
createdAt: 'desc',
},
})
برای دریافت یک پروژه عمومی با slug هم باید همین شرط را رعایت کنیم:
const project = await prisma.project.findFirst({
where: {
slug,
published: true,
},
})
این باعث میشود پروژههای پیشنویس از طریق صفحات عمومی قابل مشاهده نباشند.
دریافت یک پروژه خاص
برای کامل شدن CRUD، بهتر است متد GET یک پروژه خاص را هم داشته باشیم.
در مسیر:
app/api/projects/[id]/route.ts
کد:
export async function GET(
_request: Request,
context: RouteContext<'/api/projects/[id]'>
) {
try {
const { id } = await context.params
const project = await prisma.project.findUnique({
where: {
id,
},
})
if (!project) {
return NextResponse.json(
{
success: false,
error: 'Project not found',
},
{ status: 404 }
)
}
return NextResponse.json({
success: true,
data: project,
})
} catch {
return NextResponse.json(
{
success: false,
error: 'Internal server error',
},
{ status: 500 }
)
}
}
این endpoint بیشتر برای داشبورد یا صفحه ویرایش کاربرد دارد. مثلاً وقتی وارد صفحه ویرایش پروژه میشویم، اول اطلاعات پروژه را با GET /api/projects/[id] میگیریم و بعد فرم را با همان دادهها پر میکنیم.
فایل کامل app/api/projects/[id]/route.ts
در نهایت فایل مربوط به یک پروژه خاص میتواند اینطور باشد:
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { updateProjectSchema } from '@/lib/validations/project'
export async function GET(
_request: Request,
context: RouteContext<'/api/projects/[id]'>
) {
try {
const { id } = await context.params
const project = await prisma.project.findUnique({
where: {
id,
},
})
if (!project) {
return NextResponse.json(
{
success: false,
error: 'Project not found',
},
{ status: 404 }
)
}
return NextResponse.json({
success: true,
data: project,
})
} catch {
return NextResponse.json(
{
success: false,
error: 'Internal server error',
},
{ status: 500 }
)
}
}
export async function PATCH(
request: Request,
context: RouteContext<'/api/projects/[id]'>
) {
try {
const { id } = await context.params
const body = await request.json()
const result = updateProjectSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{
success: false,
errors: result.error.flatten().fieldErrors,
},
{ status: 400 }
)
}
const existingProject = await prisma.project.findUnique({
where: {
id,
},
})
if (!existingProject) {
return NextResponse.json(
{
success: false,
error: 'Project not found',
},
{ status: 404 }
)
}
const project = await prisma.project.update({
where: {
id,
},
data: result.data,
})
return NextResponse.json({
success: true,
data: project,
})
} catch {
return NextResponse.json(
{
success: false,
error: 'Internal server error',
},
{ status: 500 }
)
}
}
export async function DELETE(
_request: Request,
context: RouteContext<'/api/projects/[id]'>
) {
try {
const { id } = await context.params
const existingProject = await prisma.project.findUnique({
where: {
id,
},
})
if (!existingProject) {
return NextResponse.json(
{
success: false,
error: 'Project not found',
},
{ status: 404 }
)
}
await prisma.project.delete({
where: {
id,
},
})
return NextResponse.json({
success: true,
message: 'Project deleted successfully',
})
} catch {
return NextResponse.json(
{
success: false,
error: 'Internal server error',
},
{ status: 500 }
)
}
}
نکته مهم درباره احراز هویت
چون endpointهای ساخت، ویرایش و حذف پروژه مربوط به داشبورد هستند، نباید عمومی و بدون محافظت باقی بمانند.
در نسخه واقعی پروژه، قبل از انجام عملیاتهایی مثل POST، PATCH و DELETE باید بررسی کنیم کاربر وارد شده و اجازه مدیریت پروژهها را دارد.
مثلاً بهصورت ساده:
const user = await getCurrentUser()
if (!user) {
return NextResponse.json(
{
success: false,
error: 'Unauthorized',
},
{ status: 401 }
)
}
بعداً وقتی بخش auth را کامل کنیم، این قسمت را به شکل واقعی داخل endpointها قرار میدهیم.
فعلاً فقط باید مدل ذهنی را درست نگه داریم:
- endpoint عمومی برای خواندن پروژههای منتشرشده قابل قبول است
- endpointهای ساخت، ویرایش و حذف باید محافظت شوند
proxyمیتواند کمک کند، اما خودRoute Handlerهم باید بررسی امنیتی داشته باشد
جمعبندی
در این قسمت، اولین CRUD واقعی پروژه را با Next.js، Route Handlers و Prisma طراحی کردیم.
یاد گرفتیم که برای مدیریت پروژهها به این endpointها نیاز داریم:
GET /api/projects
POST /api/projects
GET /api/projects/[id]
PATCH /api/projects/[id]
DELETE /api/projects/[id]
همچنین دیدیم که:
- مدل
Projectرا درPrismaتعریف میکنیم - برای ساخت پروژه از
POSTاستفاده میکنیم - برای نمایش لیست پروژهها از
GETاستفاده میکنیم - برای ویرایش پروژه از
PATCHاستفاده میکنیم - برای حذف پروژه از
DELETEاستفاده میکنیم - برای مدیریت انتشار از فیلد
publishedاستفاده میکنیم - داده ورودی را قبل از ذخیره با
Zodاعتبارسنجی میکنیم - برای خطاهای اعتبارسنجی status code
400برمیگردانیم - برای داده پیدا نشده status code
404برمیگردانیم - برای خطای غیرمنتظره status code
500برمیگردانیم