دمج Neobrutalism مع Payload CMS: دليل عملي في Next.js
تسرّع مكتبات الواجهة تطويرك بدرجة كبيرة. في هذا المقال نربط Payload CMS بوصفه خادِم المحتوى، وNeobrutalism بوصفها طقم مكوّنات الواجهة الأمامية، ونشحن قائمة مدوّنة نيوبروتاليّة في Next.js.
Dov Azencot
@DovAzencotدليل عملي، من الطرف إلى الطرف، لربط 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 جديد
هذا يشغّل 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) بذر بضعة منشورات
افتح واجهة الإدارة ← Posts ← Create 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
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. - اضبط
serverURL(وPAYLOAD_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؛ واشحنه.