نقطة
  • الرئيسية
  • الخدمات
  • من نحن
  • الكتابات
  • تسجيل الدخول
الكتابات/tutorial/2026/03
● Tutorial8 مارس 2026·30 دقيقة

بناء موقع محتوى متكامل باستخدام Payload CMS 3 و Next.js

تعلّم كيفية بناء موقع محتوى كامل الميزات باستخدام Payload CMS 3 الذي يعمل مباشرة داخل Next.js App Router. يغطي هذا الدرس المجموعات، محرر النصوص الغنية، رفع الوسائط، المصادقة، والنشر في بيئة الإنتاج.

Noqta Team
Noqta Team
Author
·EN · FR · AR

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 3StrapiSanityContentful
البنيةداخل Next.jsخادم منفصلSaaS مستضافSaaS مستضاف
واجهة الإدارةمسار Next.jsتطبيق منفصلStudio (React)لوحة سحابية
قاعدة البياناتPostgres/MongoDBPostgres/MySQLمستضافةمستضافة
أمان الأنواعTypeScript كاملجزئيأنواع GROQأنواع SDK
استضافة ذاتيةنعم (مضمن)نعم (منفصل)لالا
APIREST + GraphQLREST + GraphQLGROQREST + 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:3000
npm 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

اختبار التطبيق

  1. لوحة الإدارة: زر /admin، أنشئ مستخدماً، وسجّل الدخول
  2. إنشاء محتوى: أضف تصنيفاً، ارفع صورة، أنشئ مقالاً
  3. الواجهة الأمامية: زر الصفحة الرئيسية لرؤية مقالاتك
  4. API: اختبر واجهة REST على /api/posts
  5. المسودة/النشر: أنشئ مقالاً كمسودة وتحقق من ظهوره فقط عند النشر
  6. التحكم بالوصول: سجّل الخروج وتحقق من إخفاء المسودات

حل المشاكل الشائعة

"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.

● الوسوم
#nextjs#payload-cms#typescript#headless-cms#react#2026#intermediate#30 دقيقة قراءة
● مشاركة
● هل لديك سؤال؟

تحدث مع وكيل نقطة بشأن هذا المقال.

Noqta Team
Noqta Team
Author · noqta
متابعة ↗

● اقرأ التالي

بناء وكيل ذكاء اصطناعي مستقل باستخدام Agentic RAG و Next.js
● Tutorial

بناء وكيل ذكاء اصطناعي مستقل باستخدام Agentic RAG و Next.js

11 فبراير 2026
بناء وكلاء الذكاء الاصطناعي من الصفر باستخدام TypeScript: إتقان نمط ReAct مع Vercel AI SDK
● Tutorial

بناء وكلاء الذكاء الاصطناعي من الصفر باستخدام TypeScript: إتقان نمط ReAct مع Vercel AI SDK

12 فبراير 2026
دليل دمج روبوت الدردشة الذكي: بناء واجهات محادثة ذكية
● Tutorial

دليل دمج روبوت الدردشة الذكي: بناء واجهات محادثة ذكية

25 يناير 2026
نقطة
الشروط والأحكام · سياسة الخصوصية
الخدمات
  • أتمتة الذكاء الاصطناعي
  • وكلاء الذكاء الاصطناعي
  • أتمتة تجربة العملاء
  • Vibe Coding
  • إدارة المشاريع
  • ضمان الجودة
  • تطوير الويب
  • تكامل API
  • تطبيقات الأعمال
  • الصيانة
  • Low-Code/No-Code
الروابط
  • معلومات عنا
  • كيف نعمل؟
  • الأخبار
  • الدروس التعليمية
  • المدونة
  • تواصل معنا
  • الأسئلة الشائعة
  • الموارد
المناطق
  • السعودية
  • الإمارات
  • قطر
  • البحرين
  • عُمان
  • ليبيا
  • تونس
  • الجزائر
  • المغرب
الشركة
  • نقطة، تونس، الهاتف +216 24 309 128
© نقطة. جميع الحقوق محفوظة.