الانتقال إلى المحتوى

14 نوفمبر 2025

دمج Neobrutalism مع Payload CMS: دليل عملي في Next.js

تسرّع مكتبات الواجهة تطويرك بدرجة كبيرة. في هذا المقال نربط Payload CMS بوصفه خادِم المحتوى، وNeobrutalism بوصفها طقم مكوّنات الواجهة الأمامية، ونشحن قائمة مدوّنة نيوبروتاليّة في Next.js.

دليل عملي، من الطرف إلى الطرف، لربط Payload CMS بوصفه خادِم المحتوى لديك وNeobrutalism بوصفها طقم مكوّنات الواجهة الأمامية — ثم شحن قائمة مدوّنة نيوبروتاليّة نظيفة في Next.js.

يعمل على نحو رائع إذا كانت حزمتك التقنية Next.js + TailwindCSS. فـ Payload أصيلة في Next وNeobrutalism تعتمد Tailwind أولًا، لذا تتلاءمان بأناقة.


ما الذي ستبنيه

  • تطبيق Payload CMS يضمّ مجموعة Posts
  • واجهة أمامية في Next.js تجلب المنشورات من REST API الخاص بـ Payload
  • صفحة /blog تعرض قائمة متجاوبة باستخدام مكوّنات Neobrutalism (Cards وBadges وButtons)

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

  • Node.js 20.9+ (تتطلّب Payload الإصدار Node 20.9 أو أحدث)
  • Next.js 15+ للواجهة الأمامية (Payload أصيلة في Next)
  • أي قاعدة بيانات متوافقة: SQLite (للتطوير) أو Postgres أو MongoDB

نصيحة: للتطوير المحلي، يمثّل SQLite أسرع مسار. انتقل إلى Postgres/Mongo عند النشر.


الجزء 1 — إعداد Payload CMS

يمكنك إضافة Payload إلى تطبيق Next.js قائم أو إنشاء تطبيق جديد. سنُنشئ مشروع Payload مخصّصًا كي يبقى الـ API الخاص به نظيفًا وقابلًا للنقل.

1) إنشاء تطبيق Payload جديد

pnpm create payload-app
# Follow the prompts: choose a template (blank or blog), set DB (SQLite for dev), etc.
cd <your-payload-app>
pnpm dev  # or npm run dev / yarn dev
npx create-payload-app
# Follow the prompts: choose a template (blank or blog), set DB (SQLite for dev), etc.
cd <your-payload-app>
pnpm dev  # or npm run dev / yarn dev
yarn create payload-app
# Follow the prompts: choose a template (blank or blog), set DB (SQLite for dev), etc.
cd <your-payload-app>
pnpm dev  # or npm run dev / yarn dev
bunx --bun create-payload-app
# Follow the prompts: choose a template (blank or blog), set DB (SQLite for dev), etc.
cd <your-payload-app>
pnpm dev  # or npm run dev / yarn dev

هذا يشغّل Payload وواجهة الإدارة (Admin UI) الخاصة به (عادةً على العنوان http://localhost:3000/admin).

2) تعريف مجموعة Posts

أنشئ الملف src/collections/Posts.ts:

import { CollectionConfig } from 'payload/types';
 
const Posts: CollectionConfig = {
  slug: 'posts',
  admin: {
    useAsTitle: 'title',
    defaultColumns: ['title', 'publishedAt', 'status'],
  },
  access: {
    read: () => true, // public read access for blog
  },
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
    },
    {
      name: 'slug',
      type: 'text',
      required: true,
      unique: true,
    },
    {
      name: 'excerpt',
      type: 'textarea',
    },
    {
      name: 'coverImage',
      type: 'upload',
      relationTo: 'media',
    },
    {
      name: 'status',
      type: 'select',
      options: [
        { label: 'Draft', value: 'draft' },
        { label: 'Published', value: 'published' },
      ],
      defaultValue: 'draft',
      required: true,
    },
    {
      name: 'publishedAt',
      type: 'date',
      admin: { position: 'sidebar' },
    },
    {
      name: 'content',
      type: 'richText',
    },
  ],
};
 
export default Posts;

أضِفها إلى الإعداد الرئيسي src/payload.config.ts:

import { buildConfig } from 'payload/config';
import Posts from './collections/Posts';
 
export default buildConfig({
  serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL,
  admin: { user: 'users' },
  collections: [
    Posts,
    // Media and Users collections if you need them
  ],
});

إذا لم تكن لديك بعد مجموعة Media أو Users، فشغّل create-payload-app بقالب مدوّنة، أو أضِف لاحقًا مجموعات بسيطة للرفع والمصادقة.

3) بذر بضعة منشورات

افتح واجهة الإدارة ← PostsCreate New وأضِف بضعة منشورات منشورة بتواريخ publishedAt سابقة.

4) تأكّد من عمل REST API

تكشف Payload واجهة REST API افتراضيًا على المسار /api/<collection>.

انتقل إلى:

http://localhost:3000/api/posts?limit=10&sort=-publishedAt&where[status][equals]=published

من المفترض أن ترى JSON يحوي منشوراتك.

عند النشر، اضبط PAYLOAD_PUBLIC_SERVER_URL ومتغيّرات بيئة قاعدة البيانات. وإذا كنت تستضيف Payload بمعزل عن Next.js، فعّل CORS لأصل الواجهة الأمامية لديك.


الجزء 2 — إعداد واجهة Next.js + Neobrutalism

سنبني تطبيق Next.js يستهلك REST API الخاص بـ Payload ويعرض قائمة مدوّنة باستخدام مكوّنات Neobrutalism.

1) إنشاء تطبيق Next.js

pnpm create next-app@latest retroui-payload-blog
cd retroui-payload-blog
npx create-next-app@latest retroui-payload-blog
cd retroui-payload-blog
yarn create next-app@latest retroui-payload-blog
cd retroui-payload-blog
bunx --bun create-next-app@latest retroui-payload-blog
cd retroui-payload-blog

2) تثبيت TailwindCSS (إذا لم تختر قالب Tailwind)

pnpm i -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

أضِف ./components/**/*.{ts,tsx} و./app/**/*.{ts,tsx} إلى محتوى tailwind.config.ts. وأدرِج كذلك مسارات Neobrutalism إن طلب المثبِّت ذلك.

3) تثبيت Neobrutalism

استخدم المثبِّت الرسمي (CLI) أو التثبيت اليدوي. مثال (pnpm):

pnpm add retroui

ثم استورد المكوّنات حيث تحتاجها. (إذا كانت Neobrutalism تأتي مع CLI لنسخ المكوّنات، فشغّله هنا واتبع التعليمات.)

4) الاتصال بـ Payload (env + مساعد fetch)

أنشئ الملف .env.local:

NEXT_PUBLIC_PAYLOAD_BASE_URL=http://localhost:3000

أضِف أداة fetch صغيرة lib/payload.ts:

export type Post = {
  id: string;
  title: string;
  slug: string;
  excerpt?: string;
  coverImage?: { url: string; filename: string } | string | null;
  status: 'draft' | 'published';
  publishedAt?: string | null;
};
 
export async function getPublishedPosts(limit = 12) {
  const base = process.env.NEXT_PUBLIC_PAYLOAD_BASE_URL;
  const url = new URL('/api/posts', base);
  url.searchParams.set('limit', String(limit));
  url.searchParams.set('sort', '-publishedAt');
  url.searchParams.set('where[status][equals]', 'published');
 
  const res = await fetch(url.toString(), { next: { revalidate: 60 } });
  if (!res.ok) throw new Error(`Failed to fetch posts: ${res.status}`);
  const data = await res.json();
  return data.docs as Post[];
}

5) بناء صفحة قائمة المدوّنة باستخدام Neobrutalism

أنشئ الملف app/blog/page.tsx (App Router):

import Image from 'next/image';
import Link from 'next/link';
import { getPublishedPosts, type Post } from '@/lib/payload';
 
// Example Neobrutalism components — adjust paths/names per your install
import { Card } from 'neobrutalism/card';
import { Button } from 'neobrutalism/button';
import { Badge } from 'neobrutalism/badge';
 
export const revalidate = 60; // ISR cadence
 
export default async function BlogListPage() {
  const posts = await getPublishedPosts(12);
 
  return (
    <main className="container mx-auto px-4 py-10">
      <h1 className="text-4xl font-extrabold mb-6">Blog</h1>
      <p className="text-muted-foreground mb-10">
        Latest posts from the Payload CMS backend, styled with Neobrutalism.
      </p>
 
      <section className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
        {posts.map((post) => (
          <Card key={post.id} className="p-0 overflow-hidden">
            {typeof post.coverImage === 'object' && post.coverImage?.url ? (
              <div className="relative h-44 w-full">
                <Image
                  src={post.coverImage.url}
                  alt={post.title}
                  fill
                  className="object-cover"
                />
              </div>
            ) : null}
 
            <div className="p-5 space-y-3">
              <div className="flex items-center gap-2">
                <Badge variant="outline">Article</Badge>
                {post.publishedAt ? (
                  <span className="text-xs opacity-70">
                    {new Date(post.publishedAt).toLocaleDateString()}
                  </span>
                ) : null}
              </div>
 
              <h2 className="text-2xl font-bold leading-tight">
                <Link href={`/blog/${post.slug}`}>{post.title}</Link>
              </h2>
 
              {post.excerpt ? (
                <p className="text-sm text-muted-foreground line-clamp-3">
                  {post.excerpt}
                </p>
              ) : null}
 
              <div className="pt-2">
                <Button asChild>
                  <Link href={`/blog/${post.slug}`}>Read more</Link>
                </Button>
              </div>
            </div>
          </Card>
        ))}
      </section>
    </main>
  );
}

إذا كانت حزمة Neobrutalism لديك تكشف أسماء/مسارات import مختلفة، فطابِق أسطر import { Card } from 'neobrutalism/card' وفقًا لذلك. ويمكنك أيضًا استبدال أي مكوّنات Neobrutalism أخرى (Tabs وInputs وما إلى ذلك).

6) صفحة تفاصيل المنشور (اختياري)

أنشئ الملف app/blog/[slug]/page.tsx:

import Image from 'next/image';
import Link from 'next/link';
 
async function getPost(slug: string) {
  const base = process.env.NEXT_PUBLIC_PAYLOAD_BASE_URL!;
  const url = new URL('/api/posts', base);
  url.searchParams.set('limit', '1');
  url.searchParams.set('where[slug][equals]', slug);
  const res = await fetch(url.toString(), { next: { revalidate: 60 } });
  if (!res.ok) throw new Error('Failed to load post');
  const data = await res.json();
  return data.docs[0];
}
 
export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  if (!post) return <div className="p-10">Not found</div>;
 
  return (
    <article className="container mx-auto px-4 py-10 max-w-3xl">
      <Link href="/blog" className="underline">← Back to blog</Link>
      <h1 className="text-4xl font-extrabold mt-3">{post.title}</h1>
      {post.coverImage?.url ? (
        <div className="relative h-80 w-full my-6">
          <Image src={post.coverImage.url} alt={post.title} fill className="object-cover" />
        </div>
      ) : null}
      {post.content?.root ? (
        // If using Payload Lexical richText renderer, render here
        <div className="prose prose-neutral dark:prose-invert">
          {/* Render your rich text */}
        </div>
      ) : (
        post.excerpt ? <p className="mt-4 text-lg">{post.excerpt}</p> : null
      )}
    </article>
  );
}

للنص الغني (rich text)، استخدم عارض Payload المفضّل لديك (مثل @payloadcms/richtext-lexical) واعرض وفقًا له.


الجزء 3 — ملاحظات حول cross-origin والنشر

إذا كان تطبيق Next.js لديك يعمل على أصل مختلف عن Payload:

  • فعّل CORS في Payload: اضبط cors: ["https://your-next-app.com", "http://localhost:3000"] في payload.config.ts.
  • اضبط serverURLPAYLOAD_PUBLIC_SERVER_URL) بصورة صحيحة كي تُحلّ عناوين الصور كما ينبغي.
  • احمِ المسوّدات: أتحنا أعلاه صلاحية read عامة. للمحتوى الخاص، استبدلها بدالة access.read تفحص المصادقة/الأدوار.

خيارات النشر:

  • الاستضافة الذاتية (تطبيق Node على خادم افتراضي خاص VPS) أو النشر إلى Vercel/Cloudflare لتجربة تطوير عصرية.
  • استخدم SQLite للعروض السريعة، وPostgres/Mongo للإنتاج.

الجزء 4 — تحسينات

  • البحث والتصفية: استخدم معاملات استعلام Payload (مثل where[title][like]) واربط Input + Tabs من Neobrutalism للتصفية.
  • الترقيم: يعيد REST API القيم totalDocs وlimit وpage — ابنِ زر «تحميل المزيد» بمكوّن Button من Neobrutalism.
  • الصور: انقل coverImage إلى مجموعة Media مخصّصة واستخدم مُحوّل الرفع في Payload.
  • وضع المعاينة: أعِدّ مسار معاينة مسوّدات في Next.js يجلب بترويسات مصادقة المسوّدات (draft-auth).
  • GraphQL: تكشف Payload كذلك واجهة GraphQL — استخدمها إن كنت تفضّل الاستعلامات المُنمّطة.

استكشاف الأخطاء وإصلاحها

  • أخطاء CORS: تحقّق من إعداد cors وتأكّد من ضبط serverURL.
  • الصور لا تظهر: تأكّد من url المُعاد للرفوعات، ومن أن next.config.js في Next.js يسمح بنطاق Payload ضمن images.domains.
  • لا شيء يُعرض: افحص عنوان fetch في lib/payload.ts. تأكّد من وجود سجلّات ومن أن status هو published.

الخلاصة

لديك الآن:

  • تطبيق Payload CMS يكشف المنشورات عبر REST
  • موقع Next.js منسَّق بـ Neobrutalism
  • صفحة /blog تُدرِج المنشورات وتربط بكلٍّ منها

من هنا، أضِف الكتّاب والفئات والوسوم؛ نسّق بمزيد من مكوّنات Neobrutalism؛ واشحنه.


→ العودة إلى المدوّنة