إطار Next.js
تكامل نظام إدارة المحتوى مع Next.js
تكامل نظام إدارة المحتوى مع Next.js
توفر منصات نظام إدارة المحتوى بدون واجهة (Headless CMS) طريقة قوية لإدارة المحتوى بشكل منفصل عن تطبيق Next.js الخاص بك. يغطي هذا الدرس دمج حلول CMS الشائعة، ونمذجة المحتوى، وبناء تطبيقات ديناميكية مدفوعة بالمحتوى.
ما هو نظام إدارة المحتوى بدون واجهة؟
نظام إدارة المحتوى بدون واجهة هو نظام لإدارة المحتوى يوفر المحتوى عبر واجهة برمجة التطبيقات (API)، دون واجهة أمامية مدمجة. تقدم هذه البنية العديد من المزايا:
- فصل الاهتمامات: إدارة المحتوى منفصلة عن العرض
- المرونة: استخدام نفس المحتوى عبر منصات متعددة (الويب، الجوال، إنترنت الأشياء)
- تجربة المطور: البناء باستخدام أطر عمل حديثة مثل Next.js
- تجربة محرر المحتوى: يمكن للمستخدمين غير التقنيين إدارة المحتوى بسهولة
- قابلية التوسع: بنية API-first قابلة للتوسع بشكل جيد
خيارات نظام إدارة المحتوى بدون واجهة الشائعة
أفضل منصات CMS لـ Next.js:
- Contentful: على مستوى المؤسسات، API ممتاز، نظام بيئي غني
- Sanity: تعاون في الوقت الفعلي، استوديو قابل للتخصيص، استعلامات GROQ
- Strapi: مفتوح المصدر، خيار استضافة ذاتية، تحكم كامل
- Prismic: Slice Machine للمحتوى القائم على المكونات
- DatoCMS: رائع للمواقع متعددة اللغات، واجهة GraphQL API
- Hygraph (GraphCMS): GraphQL الأصلي، دعم الاتحاد
تكامل Contentful
لنبدأ مع Contentful، أحد أكثر منصات نظام إدارة المحتوى بدون واجهة شيوعاً:
تثبيت Contentful SDK:
npm install contentful npm install -D @types/contentful
.env.local:
CONTENTFUL_SPACE_ID=your_space_id CONTENTFUL_ACCESS_TOKEN=your_access_token CONTENTFUL_PREVIEW_ACCESS_TOKEN=your_preview_token
lib/contentful.ts:
import { createClient } from 'contentful';
export const contentfulClient = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
});
export const previewClient = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_PREVIEW_ACCESS_TOKEN!,
host: 'preview.contentful.com',
});
export function getClient(preview: boolean = false) {
return preview ? previewClient : contentfulClient;
}
مثال نموذج المحتوى - مقالة المدونة:
// تحديد أنواع TypeScript لنموذج المحتوى الخاص بك
export interface BlogPostFields {
title: string;
slug: string;
excerpt: string;
content: Document; // نص غني
featuredImage: Asset;
author: Entry<AuthorFields>;
publishDate: string;
tags: string[];
seoTitle?: string;
seoDescription?: string;
}
export type BlogPost = Entry<BlogPostFields>;
lib/contentful/blog.ts:
import { getClient } from './contentful';
import type { BlogPost } from './types';
export async function getAllBlogPosts(
limit: number = 100,
preview: boolean = false
): Promise<BlogPost[]> {
const client = getClient(preview);
const response = await client.getEntries<BlogPostFields>({
content_type: 'blogPost',
limit,
order: '-fields.publishDate',
});
return response.items;
}
export async function getBlogPost(
slug: string,
preview: boolean = false
): Promise<BlogPost | null> {
const client = getClient(preview);
const response = await client.getEntries<BlogPostFields>({
content_type: 'blogPost',
'fields.slug': slug,
limit: 1,
});
return response.items[0] || null;
}
export async function getBlogPostsByTag(
tag: string,
limit: number = 10
): Promise<BlogPost[]> {
const client = getClient();
const response = await client.getEntries<BlogPostFields>({
content_type: 'blogPost',
'fields.tags[in]': tag,
limit,
order: '-fields.publishDate',
});
return response.items;
}
app/blog/page.tsx:
import { getAllBlogPosts } from '@/lib/contentful/blog';
import BlogCard from '@/components/BlogCard';
export const revalidate = 3600; // إعادة التحقق كل ساعة
export default async function BlogPage() {
const posts = await getAllBlogPosts();
return (
<div className="blog-page">
<h1>المدونة</h1>
<div className="blog-grid">
{posts.map((post) => (
<BlogCard key={post.sys.id} post={post} />
))}
</div>
</div>
);
}
app/blog/[slug]/page.tsx:
import { notFound } from 'next/navigation';
import { getAllBlogPosts, getBlogPost } from '@/lib/contentful/blog';
import RichText from '@/components/RichText';
export async function generateStaticParams() {
const posts = await getAllBlogPosts();
return posts.map((post) => ({
slug: post.fields.slug,
}));
}
export async function generateMetadata({ params }: Props) {
const post = await getBlogPost(params.slug);
if (!post) return {};
return {
title: post.fields.seoTitle || post.fields.title,
description: post.fields.seoDescription || post.fields.excerpt,
openGraph: {
images: [post.fields.featuredImage.fields.file.url],
},
};
}
export default async function BlogPostPage({
params,
}: {
params: { slug: string };
}) {
const post = await getBlogPost(params.slug);
if (!post) {
notFound();
}
return (
<article className="blog-post">
<header>
<h1>{post.fields.title}</h1>
<p className="excerpt">{post.fields.excerpt}</p>
<div className="meta">
<time>{new Date(post.fields.publishDate).toLocaleDateString('ar')}</time>
<span>بقلم {post.fields.author.fields.name}</span>
</div>
</header>
<img
src={post.fields.featuredImage.fields.file.url}
alt={post.fields.featuredImage.fields.title}
width={1200}
height={630}
/>
<div className="content">
<RichText content={post.fields.content} />
</div>
<footer>
<div className="tags">
{post.fields.tags.map((tag) => (
<span key={tag} className="tag">
{tag}
</span>
))}
</div>
</footer>
</article>
);
}
تكامل Sanity CMS
يوفر Sanity استوديو محتوى قابل للتخصيص بشكل كبير ولغة استعلام قوية (GROQ):
تثبيت Sanity:
npm install next-sanity @sanity/image-url npm install -D @sanity/vision
sanity.config.ts:
import { defineConfig } from 'sanity';
import { deskTool } from 'sanity/desk';
import { visionTool } from '@sanity/vision';
import { schemaTypes } from './schemas';
export default defineConfig({
name: 'default',
title: 'موقع Next.js الخاص بي',
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
plugins: [deskTool(), visionTool()],
schema: {
types: schemaTypes,
},
});
schemas/blogPost.ts:
import { defineType, defineField } from 'sanity';
export default defineType({
name: 'blogPost',
title: 'مقالة المدونة',
type: 'document',
fields: [
defineField({
name: 'title',
title: 'العنوان',
type: 'string',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'slug',
title: 'المعرف',
type: 'slug',
options: {
source: 'title',
maxLength: 96,
},
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'excerpt',
title: 'المقتطف',
type: 'text',
rows: 4,
}),
defineField({
name: 'content',
title: 'المحتوى',
type: 'array',
of: [
{ type: 'block' },
{
type: 'image',
fields: [
{
name: 'alt',
type: 'string',
title: 'النص البديل',
},
],
},
{
type: 'code',
options: {
language: 'javascript',
},
},
],
}),
defineField({
name: 'featuredImage',
title: 'الصورة المميزة',
type: 'image',
options: {
hotspot: true,
},
}),
defineField({
name: 'author',
title: 'الكاتب',
type: 'reference',
to: [{ type: 'author' }],
}),
defineField({
name: 'publishedAt',
title: 'تاريخ النشر',
type: 'datetime',
}),
defineField({
name: 'categories',
title: 'الفئات',
type: 'array',
of: [{ type: 'reference', to: { type: 'category' } }],
}),
],
preview: {
select: {
title: 'title',
author: 'author.name',
media: 'featuredImage',
},
prepare(selection) {
const { author } = selection;
return {
...selection,
subtitle: author && \`بقلم ${author}\`,
};
},
},
});
lib/sanity.ts:
import { createClient } from 'next-sanity';
import imageUrlBuilder from '@sanity/image-url';
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: '2024-01-01',
useCdn: process.env.NODE_ENV === 'production',
});
const builder = imageUrlBuilder(client);
export function urlFor(source: any) {
return builder.image(source);
}
استعلامات GROQ (lib/sanity/queries.ts):
// الحصول على جميع منشورات المدونة المنشورة
export const allPostsQuery = \`
*[_type == "blogPost" && publishedAt < now()] | order(publishedAt desc) {
_id,
title,
slug,
excerpt,
featuredImage,
publishedAt,
author->{
name,
image
},
categories[]->{
title,
slug
}
}
\`;
// الحصول على مقالة واحدة بواسطة المعرف
export const postBySlugQuery = \`
*[_type == "blogPost" && slug.current == $slug][0] {
_id,
title,
slug,
excerpt,
content,
featuredImage,
publishedAt,
author->{
name,
image,
bio
},
categories[]->{
title,
slug
}
}
\`;
// الحصول على المقالات حسب الفئة
export const postsByCategoryQuery = \`
*[_type == "blogPost" && $categorySlug in categories[]->slug.current] | order(publishedAt desc) {
_id,
title,
slug,
excerpt,
featuredImage,
publishedAt
}
\`;
جلب البيانات من Sanity:
import { client } from '@/lib/sanity';
import { allPostsQuery, postBySlugQuery } from '@/lib/sanity/queries';
export async function getAllPosts() {
return await client.fetch(allPostsQuery);
}
export async function getPostBySlug(slug: string) {
return await client.fetch(postBySlugQuery, { slug });
}
تكامل Strapi
Strapi هو نظام إدارة محتوى بدون واجهة مفتوح المصدر وذاتي الاستضافة:
.env.local:
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337 STRAPI_API_TOKEN=your_api_token
lib/strapi.ts:
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL;
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN;
async function fetchAPI(
path: string,
options: RequestInit = {}
): Promise<any> {
const defaultOptions: RequestInit = {
headers: {
'Content-Type': 'application/json',
Authorization: \`Bearer ${STRAPI_TOKEN}\`,
},
};
const mergedOptions = {
...defaultOptions,
...options,
headers: {
...defaultOptions.headers,
...options.headers,
},
};
const response = await fetch(\`${STRAPI_URL}/api${path}\`, mergedOptions);
if (!response.ok) {
throw new Error(\`خطأ في Strapi API: ${response.statusText}\`);
}
return await response.json();
}
export async function getArticles() {
const data = await fetchAPI('/articles?populate=*');
return data.data;
}
export async function getArticle(slug: string) {
const data = await fetchAPI(\`/articles?filters[slug][$eq]=${slug}&populate=*\`);
return data.data[0];
}
export async function getCategories() {
const data = await fetchAPI('/categories?populate=*');
return data.data;
}
وضع معاينة المحتوى
تنفيذ وظيفة معاينة المسودة:
app/api/preview/route.ts:
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
// التحقق من السر
if (secret !== process.env.CONTENTFUL_PREVIEW_SECRET) {
return new Response('رمز غير صالح', { status: 401 });
}
// تمكين وضع المسودة
draftMode().enable();
// إعادة التوجيه إلى المسار من المنشور الذي تم جلبه
redirect(slug || '/');
}
app/api/preview/disable/route.ts:
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET(request: Request) {
draftMode().disable();
redirect('/');
}
استخدام وضع المسودة في الصفحات:
import { draftMode } from 'next/headers';
import { getBlogPost } from '@/lib/contentful/blog';
export default async function BlogPostPage({
params,
}: {
params: { slug: string };
}) {
const { isEnabled } = draftMode();
const post = await getBlogPost(params.slug, isEnabled);
return (
<>
{isEnabled && (
<div className="preview-banner">
وضع المعاينة
<a href="/api/preview/disable">الخروج من المعاينة</a>
</div>
)}
<article>
{/* عرض المقالة */}
</article>
</>
);
}
تكامل Webhook لإعادة التحقق
إعداد webhooks لإعادة التحقق التلقائي عند تغيير المحتوى:
app/api/revalidate/route.ts:
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-revalidate-secret');
// التحقق من السر
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json(
{ message: 'سر غير صالح' },
{ status: 401 }
);
}
const body = await request.json();
try {
// إعادة التحقق من مسارات محددة
if (body.path) {
await revalidatePath(body.path);
}
// أو إعادة التحقق حسب العلامة
if (body.tag) {
revalidateTag(body.tag);
}
return NextResponse.json({ revalidated: true, now: Date.now() });
} catch (error) {
return NextResponse.json(
{ message: 'خطأ في إعادة التحقق', error },
{ status: 500 }
);
}
}
تمرين:
- قم بإعداد حساب Contentful أو Sanity مجاني
- أنشئ نموذج محتوى لمدونة مع المقالات والمؤلفين والفئات
- قم بدمج CMS مع تطبيق Next.js
- قم بتنفيذ وضع معاينة المحتوى
- قم بإعداد webhooks لإعادة التحقق التلقائي
- أضف تحسين الصور باستخدام واجهة برمجة تطبيقات صور CMS
أفضل الممارسات:
- استخدم أنواع TypeScript لنماذج المحتوى الخاصة بك
- قم بتنفيذ معالجة الأخطاء المناسبة لاستدعاءات API
- قم بتخزين استجابات CMS بشكل مناسب
- استخدم وضع المعاينة لمحرري المحتوى
- قم بإعداد webhooks لإعادة التحقق التلقائي
- قم بتحسين الصور باستخدام واجهات برمجة تطبيقات صور CMS
- فكر في استخدام GraphQL للاستعلامات المعقدة
- قم بتنفيذ الأمان المناسب باستخدام رموز API