Payload CMS 3 هو أول نظام إدارة محتوى headless يعمل داخل تطبيق Next.js. لا حاجة لخادم منفصل أو API خارجي — لوحة الإدارة والواجهة البرمجية والواجهة الأمامية كلها تعمل كتطبيق Next.js موحد. في هذا الدرس، ستبني موقع محتوى متكامل من الصفر.
ما ستبنيه
منصة TechPulse Blog — موقع محتوى كامل الميزات يتضمن:
- لوحة إدارة Payload CMS 3 مدمجة مباشرة في Next.js App Router
- مجموعات للمقالات والتصنيفات والوسائط
- محرر نصوص غني مع Lexical (المحرر الافتراضي لـ Payload)
- رفع صور مع تحسين تلقائي
- مصادقة المستخدمين والتحكم بالوصول حسب الأدوار
- سير عمل المسودة/النشر
- واجهات REST و GraphQL (تُنشأ تلقائياً)
- إدارة بيانات SEO الوصفية
- واجهة أمامية متجاوبة مع Tailwind CSS
- نشر في بيئة الإنتاج مع PostgreSQL
المتطلبات المسبقة
قبل البدء، تأكد من توفر:
- Node.js 20+ مثبت
- npm أو pnpm كمدير حزم
- معرفة أساسية بـ React و TypeScript و Next.js
- محرر أكواد (يُوصى بـ VS Code)
- Docker مثبت (لـ PostgreSQL محلي) أو رابط قاعدة بيانات سحابية
- فهم أساسي لأنظمة إدارة المحتوى
لماذا Payload CMS 3؟
يمثل Payload CMS 3 تحولاً جذرياً في طريقة عمل أنظمة إدارة المحتوى headless. بدلاً من العمل كخدمة خلفية منفصلة، يندمج Payload 3 مباشرة في تطبيق Next.js:
| الميزة | Payload CMS 3 | Strapi | Sanity | Contentful |
|---|---|---|---|---|
| البنية | داخل Next.js | خادم منفصل | SaaS مستضاف | SaaS مستضاف |
| واجهة الإدارة | مسار Next.js | تطبيق منفصل | Studio (React) | لوحة سحابية |
| قاعدة البيانات | Postgres/MongoDB | Postgres/MySQL | مستضافة | مستضافة |
| أمان الأنواع | TypeScript كامل | جزئي | أنواع GROQ | أنواع SDK |
| استضافة ذاتية | نعم (مضمن) | نعم (منفصل) | لا | لا |
| API | REST + GraphQL | REST + GraphQL | GROQ | REST + GraphQL |
| التكلفة | مجاني ومفتوح المصدر | طبقة مجانية | طبقة مجانية | طبقة مجانية |
الميزة الأساسية: قاعدة كود واحدة، نشر واحد، نموذج ذهني واحد. نظام إدارة المحتوى هو مجرد جزء آخر من تطبيق Next.js.
الخطوة 1: إنشاء المشروع
يوفر Payload أداة CLI رسمية create-payload-app لبناء مشروع كامل:
npx create-payload-app@latest techpulseعند السؤال، اختر:
- اسم المشروع:
techpulse - قاعدة البيانات: PostgreSQL (موصى به للإنتاج)
- القالب:
website(يتضمن مجموعات شائعة)
لاستخدام PostgreSQL محلي عبر Docker:
docker run -d \
--name payload-postgres \
-e POSTGRES_USER=payload \
-e POSTGRES_PASSWORD=payload123 \
-e POSTGRES_DB=techpulse \
-p 5432:5432 \
postgres:16-alpineالآن قم بالإعداد وتشغيل خادم التطوير:
cd techpulse
cp .env.example .envحدّث ملف .env:
DATABASE_URI=postgresql://payload:payload123@localhost:5432/techpulse
PAYLOAD_SECRET=your-super-secret-key-change-this-in-production
NEXT_PUBLIC_SITE_URL=http://localhost:3000npm run devزر http://localhost:3000/admin لإنشاء أول مستخدم مسؤول. واجهتك الأمامية ستكون على http://localhost:3000.
الخطوة 2: فهم بنية المشروع
إليك البنية الأساسية لمشروع Payload 3 + Next.js:
techpulse/
├── app/
│ ├── (frontend)/ # مسارات موقعك العام
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── posts/
│ │ └── [slug]/
│ │ └── page.tsx
│ ├── (payload)/ # مسارات لوحة إدارة Payload
│ │ └── admin/
│ │ └── [[...segments]]/
│ │ └── page.tsx
│ ├── api/ # واجهة REST + GraphQL لـ Payload
│ │ └── [...slug]/
│ │ └── route.ts
│ └── layout.tsx # التخطيط الجذري
├── collections/ # إعدادات مجموعات Payload
│ ├── Posts.ts
│ ├── Categories.ts
│ ├── Media.ts
│ └── Users.ts
├── globals/ # إعدادات Payload العامة
│ └── SiteSettings.ts
├── payload.config.ts # إعداد Payload الرئيسي
├── payload-types.ts # أنواع TypeScript المُنشأة تلقائياً
└── tailwind.config.ts
لاحظ كيف يستخدم Payload مجموعات مسارات Next.js: (payload) للوحة الإدارة و(frontend) لموقعك العام. يتعايشان في نفس تطبيق Next.js.
الخطوة 3: إعداد Payload
قلب نظام إدارة المحتوى هو payload.config.ts. دعنا نقوم بإعداده:
// payload.config.ts
import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { Posts } from './collections/Posts'
import { Categories } from './collections/Categories'
import { Media } from './collections/Media'
import { Users } from './collections/Users'
import { SiteSettings } from './globals/SiteSettings'
import path from 'path'
import { fileURLToPath } from 'url'
import sharp from 'sharp'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
admin: {
user: Users.slug,
meta: {
titleSuffix: '- TechPulse Admin',
},
},
collections: [Posts, Categories, Media, Users],
globals: [SiteSettings],
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URI || '',
},
}),
editor: lexicalEditor(),
sharp,
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
secret: process.env.PAYLOAD_SECRET || '',
})ملف الإعداد هذا يخبر Payload بكل شيء عن نظام إدارة المحتوى: أنواع المحتوى الموجودة، قاعدة البيانات المستخدمة، وكيف يجب أن تعمل لوحة الإدارة.
الخطوة 4: تعريف المجموعات
المجموعات هي اللبنات الأساسية لـ Payload. كل مجموعة تحدد نوع محتوى بحقوله والتحكم بالوصول والخطافات.
مجموعة المقالات
// collections/Posts.ts
import type { CollectionConfig } from 'payload'
import { lexicalEditor, HTMLConverterFeature } from '@payloadcms/richtext-lexical'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'category', 'status', 'publishedAt'],
preview: (doc) => {
if (doc?.slug) {
return `${process.env.NEXT_PUBLIC_SITE_URL}/posts/${doc.slug}`
}
return null
},
},
access: {
read: ({ req }) => {
if (req.user) return true
return { status: { equals: 'published' } }
},
create: ({ req }) => !!req.user,
update: ({ req }) => !!req.user,
delete: ({ req }) => !!req.user,
},
versions: {
drafts: {
autosave: { interval: 30000 },
},
},
fields: [
{
name: 'title',
type: 'text',
required: true,
minLength: 10,
maxLength: 120,
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
admin: {
position: 'sidebar',
description: 'معرّف صديق لعناوين URL',
},
hooks: {
beforeValidate: [
({ value, data }) => {
if (!value && data?.title) {
return data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
}
return value
},
],
},
},
{
name: 'excerpt',
type: 'textarea',
required: true,
maxLength: 300,
},
{
name: 'coverImage',
type: 'upload',
relationTo: 'media',
required: true,
},
{
name: 'content',
type: 'richText',
required: true,
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
HTMLConverterFeature({}),
],
}),
},
{
name: 'category',
type: 'relationship',
relationTo: 'categories',
required: true,
hasMany: false,
admin: { position: 'sidebar' },
},
{
name: 'tags',
type: 'array',
admin: { position: 'sidebar' },
fields: [
{ name: 'tag', type: 'text', required: true },
],
},
{
name: 'status',
type: 'select',
defaultValue: 'draft',
options: [
{ label: 'مسودة', value: 'draft' },
{ label: 'منشور', value: 'published' },
],
admin: { position: 'sidebar' },
},
{
name: 'publishedAt',
type: 'date',
admin: {
position: 'sidebar',
date: { pickerAppearance: 'dayAndTime' },
},
},
{
name: 'seo',
type: 'group',
fields: [
{ name: 'metaTitle', type: 'text', maxLength: 60 },
{ name: 'metaDescription', type: 'textarea', maxLength: 160 },
{ name: 'ogImage', type: 'upload', relationTo: 'media' },
],
},
],
hooks: {
beforeChange: [
({ data }) => {
if (data.status === 'published' && !data.publishedAt) {
data.publishedAt = new Date().toISOString()
}
return data
},
],
},
}مجموعة التصنيفات
// collections/Categories.ts
import type { CollectionConfig } from 'payload'
export const Categories: CollectionConfig = {
slug: 'categories',
admin: { useAsTitle: 'name' },
access: {
read: () => true,
create: ({ req }) => !!req.user,
update: ({ req }) => !!req.user,
delete: ({ req }) => !!req.user,
},
fields: [
{ name: 'name', type: 'text', required: true, unique: true },
{ name: 'slug', type: 'text', required: true, unique: true },
{ name: 'description', type: 'textarea' },
{ name: 'color', type: 'text' },
],
}مجموعة الوسائط
// collections/Media.ts
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
admin: { useAsTitle: 'alt' },
access: {
read: () => true,
create: ({ req }) => !!req.user,
update: ({ req }) => !!req.user,
delete: ({ req }) => !!req.user,
},
upload: {
staticDir: 'public/media',
mimeTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'],
imageSizes: [
{ name: 'thumbnail', width: 400, height: 300, position: 'centre' },
{ name: 'card', width: 768, height: 432, position: 'centre' },
{ name: 'hero', width: 1920, height: undefined, position: 'centre' },
],
},
fields: [
{ name: 'alt', type: 'text', required: true },
{ name: 'caption', type: 'text' },
],
}مجموعة المستخدمين
// collections/Users.ts
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
admin: { useAsTitle: 'email' },
access: {
read: () => true,
create: ({ req }) => req.user?.role === 'admin',
update: ({ req, id }) => {
if (req.user?.role === 'admin') return true
return req.user?.id === id
},
delete: ({ req }) => req.user?.role === 'admin',
},
fields: [
{ name: 'name', type: 'text', required: true },
{
name: 'role',
type: 'select',
defaultValue: 'editor',
options: [
{ label: 'مسؤول', value: 'admin' },
{ label: 'محرر', value: 'editor' },
],
access: { update: ({ req }) => req.user?.role === 'admin' },
admin: { position: 'sidebar' },
},
{ name: 'avatar', type: 'upload', relationTo: 'media' },
],
}الخطوة 5: إنشاء الإعدادات العامة
الإعدادات العامة (Globals) هي مستندات فردية — محتوى موجود مرة واحدة فقط، مثل إعدادات الموقع:
// globals/SiteSettings.ts
import type { GlobalConfig } from 'payload'
export const SiteSettings: GlobalConfig = {
slug: 'site-settings',
access: {
read: () => true,
update: ({ req }) => !!req.user,
},
fields: [
{ name: 'siteName', type: 'text', required: true, defaultValue: 'TechPulse' },
{ name: 'siteDescription', type: 'textarea', defaultValue: 'مصدرك اليومي للرؤى والدروس التقنية.' },
{ name: 'logo', type: 'upload', relationTo: 'media' },
{
name: 'socialLinks',
type: 'group',
fields: [
{ name: 'twitter', type: 'text' },
{ name: 'github', type: 'text' },
{ name: 'linkedin', type: 'text' },
],
},
{
name: 'footer',
type: 'group',
fields: [
{ name: 'copyright', type: 'text', defaultValue: '© 2026 TechPulse. جميع الحقوق محفوظة.' },
{
name: 'links',
type: 'array',
fields: [
{ name: 'label', type: 'text', required: true },
{ name: 'url', type: 'text', required: true },
],
},
],
},
],
}الخطوة 6: بناء الواجهة الأمامية
الآن لنبنِ الموقع العام. يوفر Payload دالة getPayload التي تمنحك وصولاً مباشراً لبيانات نظام إدارة المحتوى — لا حاجة لاستدعاءات API لأن Payload يعمل داخل تطبيق Next.js.
مكون التخطيط
// app/(frontend)/layout.tsx
import type { Metadata } from 'next'
import { getPayload } from 'payload'
import config from '@payload-config'
import Link from 'next/link'
import './globals.css'
export async function generateMetadata(): Promise<Metadata> {
const payload = await getPayload({ config })
const settings = await payload.findGlobal({ slug: 'site-settings' })
return {
title: {
default: settings.siteName,
template: `%s | ${settings.siteName}`,
},
description: settings.siteDescription,
}
}
export default async function FrontendLayout({
children,
}: {
children: React.ReactNode
}) {
const payload = await getPayload({ config })
const settings = await payload.findGlobal({ slug: 'site-settings' })
const categories = await payload.find({
collection: 'categories',
limit: 10,
sort: 'name',
})
return (
<div className="min-h-screen flex flex-col">
<header className="border-b bg-white sticky top-0 z-50">
<nav className="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
<Link href="/" className="text-xl font-bold text-gray-900">
{settings.siteName}
</Link>
<div className="hidden md:flex items-center gap-6">
{categories.docs.map((category) => (
<Link
key={category.id}
href={`/category/${category.slug}`}
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
>
{category.name}
</Link>
))}
</div>
</nav>
</header>
<main className="flex-1">{children}</main>
<footer className="border-t bg-gray-50 py-8">
<div className="max-w-6xl mx-auto px-4 text-center text-sm text-gray-500">
{settings.footer?.copyright}
</div>
</footer>
</div>
)
}الصفحة الرئيسية — قائمة المقالات
// app/(frontend)/page.tsx
import { getPayload } from 'payload'
import config from '@payload-config'
import Link from 'next/link'
import Image from 'next/image'
import type { Post, Media, Category } from '@/payload-types'
export default async function HomePage() {
const payload = await getPayload({ config })
const posts = await payload.find({
collection: 'posts',
where: { status: { equals: 'published' } },
sort: '-publishedAt',
limit: 12,
depth: 2,
})
const [featured, ...rest] = posts.docs
return (
<div className="max-w-6xl mx-auto px-4 py-12">
{featured && <FeaturedPost post={featured} />}
<section className="mt-16">
<h2 className="text-2xl font-bold mb-8">أحدث المقالات</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{rest.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</section>
</div>
)
}
function FeaturedPost({ post }: { post: Post }) {
const coverImage = post.coverImage as Media
const category = post.category as Category
return (
<Link href={`/posts/${post.slug}`} className="group block">
<article className="grid md:grid-cols-2 gap-8 items-center">
<div className="relative aspect-video rounded-xl overflow-hidden">
{coverImage?.url && (
<Image
src={coverImage.url}
alt={coverImage.alt}
fill
className="object-cover group-hover:scale-105 transition-transform duration-500"
priority
/>
)}
</div>
<div>
{category && (
<span
className="inline-block px-3 py-1 text-xs font-medium rounded-full text-white mb-4"
style={{ backgroundColor: category.color || '#3B82F6' }}
>
{category.name}
</span>
)}
<h1 className="text-3xl font-bold mb-4 group-hover:text-blue-600 transition-colors">
{post.title}
</h1>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
</div>
</article>
</Link>
)
}
function PostCard({ post }: { post: Post }) {
const coverImage = post.coverImage as Media
const category = post.category as Category
return (
<Link href={`/posts/${post.slug}`} className="group block">
<article>
<div className="relative aspect-video rounded-lg overflow-hidden mb-4">
{coverImage?.url && (
<Image
src={coverImage.url}
alt={coverImage.alt}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
/>
)}
</div>
{category && (
<span
className="inline-block px-2 py-0.5 text-xs font-medium rounded-full text-white mb-2"
style={{ backgroundColor: category.color || '#3B82F6' }}
>
{category.name}
</span>
)}
<h3 className="font-semibold mb-2 group-hover:text-blue-600 transition-colors line-clamp-2">
{post.title}
</h3>
<p className="text-sm text-gray-600 line-clamp-2">{post.excerpt}</p>
</article>
</Link>
)
}صفحة المقال الفردي
// app/(frontend)/posts/[slug]/page.tsx
import { getPayload } from 'payload'
import config from '@payload-config'
import { notFound } from 'next/navigation'
import Image from 'next/image'
import type { Metadata } from 'next'
import type { Post, Media, Category } from '@/payload-types'
import { RichText } from '@payloadcms/richtext-lexical/react'
type Params = Promise<{ slug: string }>
export async function generateStaticParams() {
const payload = await getPayload({ config })
const posts = await payload.find({
collection: 'posts',
where: { status: { equals: 'published' } },
limit: 100,
select: { slug: true },
})
return posts.docs.map((post) => ({ slug: post.slug }))
}
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { slug } = await params
const payload = await getPayload({ config })
const posts = await payload.find({
collection: 'posts',
where: { slug: { equals: slug } },
limit: 1,
})
const post = posts.docs[0]
if (!post) return { title: 'مقال غير موجود' }
const coverImage = post.coverImage as Media
return {
title: post.seo?.metaTitle || post.title,
description: post.seo?.metaDescription || post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: coverImage?.url ? [{ url: coverImage.url }] : [],
},
}
}
export default async function PostPage({ params }: { params: Params }) {
const { slug } = await params
const payload = await getPayload({ config })
const posts = await payload.find({
collection: 'posts',
where: {
slug: { equals: slug },
status: { equals: 'published' },
},
limit: 1,
depth: 2,
})
const post = posts.docs[0]
if (!post) notFound()
const coverImage = post.coverImage as Media
const category = post.category as Category
return (
<article className="max-w-3xl mx-auto px-4 py-12">
<header className="mb-8">
{category && (
<span
className="inline-block px-3 py-1 text-xs font-medium rounded-full text-white mb-4"
style={{ backgroundColor: category.color || '#3B82F6' }}
>
{category.name}
</span>
)}
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<p className="text-lg text-gray-600 mb-4">{post.excerpt}</p>
</header>
{coverImage?.url && (
<div className="relative aspect-video rounded-xl overflow-hidden mb-12">
<Image src={coverImage.url} alt={coverImage.alt} fill className="object-cover" priority />
</div>
)}
<div className="prose prose-lg max-w-none">
<RichText data={post.content} />
</div>
{post.tags && post.tags.length > 0 && (
<div className="mt-12 pt-8 border-t flex flex-wrap gap-2">
{post.tags.map((item, i) => (
<span key={i} className="px-3 py-1 bg-gray-100 text-sm text-gray-600 rounded-full">
#{item.tag}
</span>
))}
</div>
)}
</article>
)
}الخطوة 7: إضافة مسار API لـ Payload
ينشئ Payload تلقائياً واجهات REST و GraphQL. تحتاج مسار catch-all:
// app/api/[...slug]/route.ts
import { REST_DELETE, REST_GET, REST_PATCH, REST_POST } from '@payloadcms/next/routes'
import config from '@payload-config'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const PATCH = REST_PATCH(config)
export const DELETE = REST_DELETE(config)هذا يمنحك نقاط نهاية API تلقائية:
GET /api/posts— قائمة جميع المقالاتGET /api/posts/:id— الحصول على مقال واحدPOST /api/posts— إنشاء مقالPATCH /api/posts/:id— تحديث مقالDELETE /api/posts/:id— حذف مقالPOST /api/users/login— المصادقة
الخطوة 8: بذر البيانات الأولية
أنشئ سكريبت بذر لملء نظام إدارة المحتوى ببيانات أولية:
// scripts/seed.ts
import { getPayload } from 'payload'
import config from '@payload-config'
async function seed() {
const payload = await getPayload({ config })
console.log('جارٍ بذر قاعدة البيانات...')
const categories = [
{ name: 'هندسة البرمجيات', slug: 'engineering', color: '#3B82F6', description: 'هندسة وتطوير البرمجيات' },
{ name: 'الذكاء الاصطناعي', slug: 'ai-ml', color: '#8B5CF6', description: 'الذكاء الاصطناعي وتعلم الآلة' },
{ name: 'DevOps', slug: 'devops', color: '#10B981', description: 'البنية التحتية والعمليات' },
{ name: 'التصميم', slug: 'design', color: '#F59E0B', description: 'تصميم واجهة المستخدم وتجربة المستخدم' },
]
for (const category of categories) {
await payload.create({ collection: 'categories', data: category })
console.log(`تم إنشاء التصنيف: ${category.name}`)
}
console.log('اكتمل البذر!')
process.exit(0)
}
seed()شغّله:
npx tsx scripts/seed.tsالخطوة 9: إضافة أنماط التحكم بالوصول
التحكم بالوصول في Payload هو أحد أقوى ميزاته. إليك الأنماط الشائعة:
// lib/access.ts
import type { Access } from 'payload'
// المسؤولون فقط يمكنهم تنفيذ هذا الإجراء
export const isAdmin: Access = ({ req }) => {
return req.user?.role === 'admin'
}
// المسؤولون يرون كل شيء، الآخرون يرون مستنداتهم فقط
export const isAdminOrSelf: Access = ({ req }) => {
if (req.user?.role === 'admin') return true
return { id: { equals: req.user?.id } }
}
// المحتوى المنشور عام، المسودات تتطلب مصادقة
export const publishedOrAuth: Access = ({ req }) => {
if (req.user) return true
return { status: { equals: 'published' } }
}الخطوة 10: إضافة خطافات المجموعات
الخطافات تتيح لك تنفيذ منطق في نقاط مختلفة من دورة حياة المستند:
hooks: {
beforeChange: [
({ data }) => {
if (data.status === 'published' && !data.publishedAt) {
data.publishedAt = new Date().toISOString()
}
return data
},
],
afterChange: [
async ({ doc, operation }) => {
if (operation === 'create' && doc.status === 'published') {
try {
await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/revalidate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
secret: process.env.REVALIDATION_SECRET,
path: `/posts/${doc.slug}`,
}),
})
} catch (error) {
console.error('فشل إعادة التحقق:', error)
}
}
},
],
}الخطوة 11: إعداد إعادة التحقق عند الطلب
أنشئ مسار API لإعادة التحقق حتى تنعكس تحديثات المحتوى فوراً:
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const body = await request.json()
if (body.secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ message: 'رمز سري غير صالح' }, { status: 401 })
}
if (body.path) {
revalidatePath(body.path)
return NextResponse.json({ revalidated: true, path: body.path })
}
revalidatePath('/')
return NextResponse.json({ revalidated: true, path: '/' })
}الخطوة 12: النشر في بيئة الإنتاج
الخيار أ: النشر على Vercel
Payload CMS 3 يعمل بسلاسة مع Vercel. ستحتاج قاعدة بيانات PostgreSQL مستضافة:
npm i -g vercel
vercelعيّن متغيرات البيئة في لوحة Vercel:
DATABASE_URI=postgresql://...
PAYLOAD_SECRET=your-production-secret
NEXT_PUBLIC_SITE_URL=https://your-domain.com
REVALIDATION_SECRET=your-revalidation-secret
الخيار ب: النشر مع Docker
FROM node:20-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]docker compose up -dاختبار التطبيق
- لوحة الإدارة: زر
/admin، أنشئ مستخدماً، وسجّل الدخول - إنشاء محتوى: أضف تصنيفاً، ارفع صورة، أنشئ مقالاً
- الواجهة الأمامية: زر الصفحة الرئيسية لرؤية مقالاتك
- API: اختبر واجهة REST على
/api/posts - المسودة/النشر: أنشئ مقالاً كمسودة وتحقق من ظهوره فقط عند النشر
- التحكم بالوصول: سجّل الخروج وتحقق من إخفاء المسودات
حل المشاكل الشائعة
"Cannot find module '@payload-config'"
تأكد من وجود المسار في tsconfig.json:
{
"compilerOptions": {
"paths": {
"@payload-config": ["./payload.config.ts"]
}
}
}أخطاء الاتصال بقاعدة البيانات
تحقق من صحة DATABASE_URI وأن خادم PostgreSQL يعمل.
الصور لا تظهر
تأكد من وجود مجلد public/media وأن لديه صلاحيات الكتابة.
أخطاء الأنواع بعد تغيير المخطط أعد توليد الأنواع:
npx payload generate:typesالخطوات التالية
- إضافة بحث باستخدام إضافة البحث المدمجة في Payload أو دمج Algolia
- إضافة i18n مع ميزة التعريب في Payload للمحتوى متعدد اللغات
- إعداد وضع المعاينة للمحررين لمعاينة المسودات على الموقع المباشر
- إضافة مكونات مخصصة للوحة الإدارة
- تنفيذ webhooks لإشعار الخدمات الخارجية عند تغيير المحتوى
الخلاصة
لقد بنيت موقع محتوى متكامل مع Payload CMS 3 يعمل مباشرة داخل Next.js. هذه البنية تمنحك أفضل ما في العالمين: لوحة إدارة قوية وواجهة برمجية بدون تعقيد في النشر — كل شيء يعمل كتطبيق واحد.
النقاط الرئيسية:
- Payload CMS 3 يعيش داخل Next.js — لا حاجة لخلفية منفصلة
- المجموعات تحدد نموذج محتواك مع أنواع TypeScript كاملة
- التحكم بالوصول قائم على الكود — مرن وقابل للاختبار
- الخطافات توفر أتمتة دورة الحياة — لا حاجة لسير عمل خارجي
- استعلامات مباشرة لقاعدة البيانات عبر
getPayload()— لا عبء REST على الخادم - واجهات API تُنشأ تلقائياً للمستهلكين الخارجيين
مزيج Payload + Next.js هو أحد أكثر الأنظمة إنتاجية لبناء تطبيقات ثقيلة المحتوى في 2026.