بناء تطبيق Full-Stack باستخدام Strapi 5 و Next.js 15 App Router

Strapi 5 + Next.js 15 — التركيبة المثالية للمحتوى الديناميكي. يمنحك Strapi نظام إدارة محتوى مرنًا مفتوح المصدر مع API جاهز تلقائيًا، بينما يوفر Next.js 15 Server Components أداءً استثنائيًا وتحسينًا متقدمًا لمحركات البحث. في هذا الدليل الشامل ستبني منصة مدونة كاملة من الصفر.
ما ستتعلمه
بنهاية هذا الدليل الشامل، ستكون قادرًا على:
- إعداد Strapi 5 وتكوينه كنظام إدارة محتوى headless
- تعريف أنواع المحتوى (Content Types) بالعلاقات والحقول المخصصة
- تكوين صلاحيات API للوصول العام والمحمي
- بناء عميل API آمن الأنواع مع TypeScript
- عرض المحتوى باستخدام Next.js 15 Server Components
- تنفيذ Draft Mode لمعاينة المسودات
- إضافة دعم تعدد اللغات عبر Strapi i18n
- نشر التطبيق في بيئة الإنتاج
المتطلبات الأساسية
قبل البدء، تأكد من توفر:
- Node.js 20+ (
node --version) - خبرة في TypeScript (الأنواع، الواجهات، async/await)
- معرفة بأساسيات Next.js App Router و Server Components
- محرر أكواد — يُنصح بـ VS Code أو Cursor مع إضافة TypeScript
لماذا Strapi 5؟
Strapi هو أكثر أنظمة إدارة المحتوى الـ headless شيوعًا مفتوحة المصدر، يستخدمه أكثر من 60,000 مشروع في الإنتاج. الإصدار الخامس جلب تحسينات جوهرية في الأداء والبنية الداخلية.
مقارنة بين أشهر الأنظمة
| الخاصية | Strapi 5 | Payload CMS | Directus | Sanity | WordPress |
|---|---|---|---|---|---|
| المصدر المفتوح | نعم كاملًا | نعم | نعم | جزئي | نعم |
| TypeScript أصلي | نعم | نعم كاملًا | جزئي | نعم | لا |
| لوحة تحكم مدمجة | نعم | نعم | نعم | نعم | نعم |
| REST + GraphQL | كلاهما | كلاهما | كلاهما | GraphQL | REST |
| الاستضافة الذاتية | نعم | نعم | نعم | محدود | نعم |
| سهولة الإعداد | عالية | متوسطة | متوسطة | عالية | عالية |
| قاعدة البيانات | متعددة | متعددة | متعددة | سحابية | MySQL |
| السعر المجاني | مفتوح المصدر | مفتوح المصدر | مفتوح المصدر | محدود | مفتوح المصدر |
يتميز Strapi 5 بـ:
- API تلقائي (REST و GraphQL) بمجرد تعريف أنواع المحتوى
- واجهة لوحة تحكم بصرية لإدارة المحتوى بدون كود
- نظام صلاحيات متكامل وقابل للتخصيص
- دعم i18n مدمج
- مجتمع ضخم ووثائق ممتازة
ما ستبنيه
منصة مدونة متكاملة تضم:
- مقالات مع صور غلاف، محتوى نصي، وتاريخ نشر
- فئات لتنظيم المقالات
- مؤلفون مع صور وسير ذاتية
- صفحة قائمة المقالات مع التصفية حسب الفئة
- صفحة تفاصيل كل مقال
- دعم المسودات والنشر
- دعم اللغة العربية والإنجليزية
الخطوة 1: إنشاء مشروع Strapi 5
تثبيت Strapi
افتح الطرفية وشغّل:
npx create-strapi@latest my-blog-cmsسيطرح المثبّت أسئلة، اختر الخيارات التالية:
? Would you like to use the default database (sqlite)? Yes
? Start with an example structure & data? No
? Start with Typescript? Yes
? Install dependencies with npm? Yes
? Initialize a git repository? Yes
بعد اكتمال التثبيت:
cd my-blog-cms
npm run developتلميح: استخدام SQLite ممتاز للتطوير المحلي. في الإنتاج، انتقل إلى PostgreSQL أو MySQL لأداء أفضل.
بنية مشروع Strapi
my-blog-cms/
├── config/
│ ├── database.ts # إعداد قاعدة البيانات
│ ├── middlewares.ts # الـ Middleware
│ ├── plugins.ts # إعداد الإضافات
│ └── server.ts # إعداد الخادم
├── src/
│ ├── api/ # أنواع المحتوى (تُنشأ تلقائيًا)
│ ├── admin/ # تخصيصات لوحة التحكم
│ └── extensions/ # تمديد الـ plugins
├── public/
│ └── uploads/ # ملفات الوسائط المرفوعة
└── package.json
افتح المتصفح على العنوان http://localhost:1337/admin وأنشئ حساب المشرف.
الخطوة 2: تعريف أنواع المحتوى
إنشاء نوع Author
في لوحة تحكم Strapi:
- اذهب إلى Content-Type Builder
- انقر على Create new collection type
- أدخل الاسم:
Author - أضف الحقول التالية:
| اسم الحقل | النوع | الخصائص |
|---|---|---|
name | Text | مطلوب |
bio | Rich Text | اختياري |
avatar | Media (صورة واحدة) | اختياري |
email | مطلوب، فريد |
انقر Save لحفظ النوع وإعادة تشغيل Strapi تلقائيًا.
إنشاء نوع Category
أنشئ collection type جديد باسم Category:
| اسم الحقل | النوع | الخصائص |
|---|---|---|
name | Text | مطلوب |
slug | UID (من name) | مطلوب |
description | Text | اختياري |
إنشاء نوع Article
أنشئ collection type باسم Article:
| اسم الحقل | النوع | الخصائص |
|---|---|---|
title | Text | مطلوب |
slug | UID (من title) | مطلوب |
content | Rich Text (Blocks) | مطلوب |
excerpt | Text | اختياري |
cover | Media (صورة واحدة) | اختياري |
publishedAt | Date | اختياري |
author | Relation → Author | Many-to-one |
category | Relation → Category | Many-to-one |
Rich Text Blocks في Strapi 5: الإصدار الخامس يستخدم محرر Blocks الجديد بدلاً من Markdown. يُعيد البيانات كمصفوفة JSON منظمة أسهل للتصيير.
تفعيل Draft/Publish
في إعدادات نوع Article، فعّل خيار Draft & Publish. هذا يضيف حقل publishedAt تلقائيًا ويتيح نشر المقالات بشكل منفصل.
الخطوة 3: تكوين صلاحيات API
بعد تعريف أنواع المحتوى، يجب منح الإذن للوصول العام:
- اذهب إلى Settings → Users & Permissions Plugin → Roles
- انقر على دور Public
- في قسم Permissions:
- Article: فعّل
findوfindOne - Category: فعّل
findوfindOne - Author: فعّل
findوfindOne
- Article: فعّل
- انقر Save
اختبار الـ API
# جلب كل المقالات المنشورة
curl http://localhost:1337/api/articles?populate=*
# جلب مقال محدد
curl http://localhost:1337/api/articles/1?populate=*
# جلب المقالات مع الفئة والمؤلف
curl "http://localhost:1337/api/articles?populate[author][fields][0]=name&populate[category][fields][0]=name&populate[cover][fields][0]=url"الخطوة 4: إنشاء مشروع Next.js 15
في مجلد مختلف، أنشئ مشروع Next.js:
npx create-next-app@latest my-blog-frontend \
--typescript \
--tailwind \
--app \
--src-dir \
--import-alias "@/*"
cd my-blog-frontendتثبيت المكتبات اللازمة
npm install qs
npm install -D @types/qsqs مكتبة ممتازة لبناء query strings معقدة مثل populate[author][fields][0]=name.
إعداد متغيرات البيئة
أنشئ ملف .env.local:
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your_api_token_hereإنشاء API Token في Strapi
- اذهب إلى Settings → API Tokens
- انقر Create new API Token
- أدخل الاسم:
Next.js Frontend - اختر النوع: Read-only
- انسخ الـ token وضعه في
.env.local
الخطوة 5: بناء عميل Strapi API
أنشئ الملف src/lib/strapi.ts:
import qs from "qs";
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || "http://localhost:1337";
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN;
// أنواع البيانات الأساسية من Strapi
export interface StrapiImage {
id: number;
url: string;
alternativeText: string | null;
width: number;
height: number;
formats?: {
thumbnail?: { url: string; width: number; height: number };
small?: { url: string; width: number; height: number };
medium?: { url: string; width: number; height: number };
large?: { url: string; width: number; height: number };
};
}
export interface StrapiAuthor {
id: number;
name: string;
bio: string | null;
email: string;
avatar: StrapiImage | null;
}
export interface StrapiCategory {
id: number;
name: string;
slug: string;
description: string | null;
}
export interface StrapiBlock {
type: string;
children?: Array<{ type: string; text?: string; bold?: boolean; italic?: boolean }>;
level?: number;
image?: StrapiImage;
url?: string;
}
export interface StrapiArticle {
id: number;
title: string;
slug: string;
content: StrapiBlock[];
excerpt: string | null;
cover: StrapiImage | null;
publishedAt: string | null;
createdAt: string;
updatedAt: string;
author: StrapiAuthor | null;
category: StrapiCategory | null;
}
export interface StrapiResponse<T> {
data: T;
meta: {
pagination?: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
// دالة الجلب الأساسية
async function strapiRequest<T>(
endpoint: string,
params?: Record<string, unknown>,
options?: RequestInit
): Promise<StrapiResponse<T>> {
const queryString = params ? `?${qs.stringify(params, { encodeValuesOnly: true })}` : "";
const url = `${STRAPI_URL}/api${endpoint}${queryString}`;
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (STRAPI_TOKEN) {
headers["Authorization"] = `Bearer ${STRAPI_TOKEN}`;
}
const response = await fetch(url, {
headers,
...options,
});
if (!response.ok) {
throw new Error(`Strapi API Error: ${response.status} ${response.statusText}`);
}
return response.json();
}
// خيارات populate الافتراضية للمقالات
const ARTICLE_POPULATE = {
populate: {
cover: {
fields: ["url", "alternativeText", "width", "height", "formats"],
},
author: {
fields: ["name", "email"],
populate: {
avatar: { fields: ["url", "alternativeText"] },
},
},
category: {
fields: ["name", "slug"],
},
},
};
// جلب كل المقالات
export async function getArticles(params?: {
page?: number;
pageSize?: number;
categorySlug?: string;
locale?: string;
}): Promise<StrapiResponse<StrapiArticle[]>> {
const queryParams: Record<string, unknown> = {
...ARTICLE_POPULATE,
sort: ["publishedAt:desc"],
pagination: {
page: params?.page || 1,
pageSize: params?.pageSize || 10,
},
};
if (params?.categorySlug) {
queryParams.filters = {
category: { slug: { $eq: params.categorySlug } },
};
}
if (params?.locale) {
queryParams.locale = params.locale;
}
return strapiRequest<StrapiArticle[]>("/articles", queryParams);
}
// جلب مقال واحد بالـ slug
export async function getArticleBySlug(
slug: string,
options?: { preview?: boolean; locale?: string }
): Promise<StrapiArticle | null> {
const queryParams: Record<string, unknown> = {
...ARTICLE_POPULATE,
populate: {
...ARTICLE_POPULATE.populate,
},
filters: { slug: { $eq: slug } },
};
if (options?.locale) {
queryParams.locale = options.locale;
}
// في وضع المعاينة، نجلب المسودات أيضًا
if (options?.preview) {
queryParams.publicationState = "preview";
}
const response = await strapiRequest<StrapiArticle[]>("/articles", queryParams);
return response.data[0] || null;
}
// جلب كل الـ slugs للمقالات (لـ generateStaticParams)
export async function getAllArticleSlugs(): Promise<string[]> {
const response = await strapiRequest<Array<{ slug: string }>>("/articles", {
fields: ["slug"],
pagination: { pageSize: 1000 },
});
return response.data.map((article) => article.slug);
}
// جلب كل الفئات
export async function getCategories(): Promise<StrapiCategory[]> {
const response = await strapiRequest<StrapiCategory[]>("/categories", {
fields: ["name", "slug", "description"],
sort: ["name:asc"],
});
return response.data;
}
// جلب فئة واحدة بالـ slug
export async function getCategoryBySlug(slug: string): Promise<StrapiCategory | null> {
const response = await strapiRequest<StrapiCategory[]>("/categories", {
filters: { slug: { $eq: slug } },
fields: ["name", "slug", "description"],
});
return response.data[0] || null;
}
// بناء URL الصورة الكاملة
export function getStrapiImageUrl(image: StrapiImage | null, size?: "thumbnail" | "small" | "medium" | "large"): string {
if (!image) return "/images/placeholder.webp";
if (size && image.formats?.[size]) {
const format = image.formats[size]!;
return image.url.startsWith("http") ? format.url : `${STRAPI_URL}${format.url}`;
}
return image.url.startsWith("http") ? image.url : `${STRAPI_URL}${image.url}`;
}الخطوة 6: عرض قائمة المقالات
أنشئ الملف src/app/page.tsx لصفحة المقالات الرئيسية:
import Image from "next/image";
import Link from "next/link";
import { getArticles, getCategories, getStrapiImageUrl } from "@/lib/strapi";
import type { StrapiArticle } from "@/lib/strapi";
interface HomePageProps {
searchParams: Promise<{ category?: string; page?: string }>;
}
export default async function HomePage({ searchParams }: HomePageProps) {
const params = await searchParams;
const currentPage = Number(params.page) || 1;
const categorySlug = params.category;
const [articlesResponse, categories] = await Promise.all([
getArticles({
page: currentPage,
pageSize: 9,
categorySlug,
}),
getCategories(),
]);
const { data: articles, meta } = articlesResponse;
const totalPages = meta.pagination?.pageCount || 1;
return (
<main className="max-w-6xl mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-2">المدونة</h1>
<p className="text-gray-600 mb-8">آخر المقالات والأدلة التقنية</p>
{/* قائمة الفئات */}
<div className="flex flex-wrap gap-2 mb-8">
<Link
href="/"
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
!categorySlug
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
الكل
</Link>
{categories.map((cat) => (
<Link
key={cat.id}
href={`/?category=${cat.slug}`}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
categorySlug === cat.slug
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{cat.name}
</Link>
))}
</div>
{/* شبكة المقالات */}
{articles.length === 0 ? (
<div className="text-center py-16 text-gray-500">
لا توجد مقالات في هذه الفئة
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{articles.map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
)}
{/* ترقيم الصفحات */}
{totalPages > 1 && (
<div className="flex justify-center gap-2 mt-10">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<Link
key={page}
href={`/?page=${page}${categorySlug ? `&category=${categorySlug}` : ""}`}
className={`w-10 h-10 flex items-center justify-center rounded-lg font-medium ${
page === currentPage
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{page}
</Link>
))}
</div>
)}
</main>
);
}
function ArticleCard({ article }: { article: StrapiArticle }) {
const coverUrl = getStrapiImageUrl(article.cover, "medium");
const publishDate = article.publishedAt
? new Date(article.publishedAt).toLocaleDateString("ar-TN", {
year: "numeric",
month: "long",
day: "numeric",
})
: null;
return (
<Link href={`/articles/${article.slug}`} className="group block">
<article className="bg-white rounded-xl overflow-hidden shadow-sm border border-gray-100 hover:shadow-md transition-shadow h-full flex flex-col">
{/* صورة الغلاف */}
<div className="relative aspect-video overflow-hidden">
<Image
src={coverUrl}
alt={article.cover?.alternativeText || article.title}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
</div>
{/* محتوى البطاقة */}
<div className="p-5 flex flex-col flex-1">
{article.category && (
<span className="text-xs font-semibold text-blue-600 uppercase tracking-wider mb-2">
{article.category.name}
</span>
)}
<h2 className="text-lg font-bold text-gray-900 mb-2 group-hover:text-blue-600 transition-colors line-clamp-2">
{article.title}
</h2>
{article.excerpt && (
<p className="text-gray-500 text-sm line-clamp-3 flex-1">
{article.excerpt}
</p>
)}
{/* معلومات المؤلف والتاريخ */}
<div className="flex items-center gap-3 mt-4 pt-4 border-t border-gray-100">
{article.author?.avatar && (
<Image
src={getStrapiImageUrl(article.author.avatar, "thumbnail")}
alt={article.author.name}
width={32}
height={32}
className="rounded-full object-cover"
/>
)}
<div className="flex-1 min-w-0">
{article.author && (
<p className="text-sm font-medium text-gray-900 truncate">
{article.author.name}
</p>
)}
{publishDate && (
<p className="text-xs text-gray-400">{publishDate}</p>
)}
</div>
</div>
</div>
</article>
</Link>
);
}إضافة إعداد Next.js للصور
في next.config.ts، أضف domain لـ Strapi:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "http",
hostname: "localhost",
port: "1337",
pathname: "/uploads/**",
},
{
protocol: "https",
hostname: "your-strapi-domain.com",
pathname: "/uploads/**",
},
],
},
};
export default nextConfig;الخطوة 7: صفحة تفاصيل المقال
مصيّر Blocks
أنشئ src/components/BlocksRenderer.tsx لتصيير محتوى Strapi Blocks:
import Image from "next/image";
import type { StrapiBlock, StrapiImage } from "@/lib/strapi";
import { getStrapiImageUrl } from "@/lib/strapi";
interface BlocksRendererProps {
blocks: StrapiBlock[];
}
interface TextChild {
type: string;
text?: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
code?: boolean;
url?: string;
children?: TextChild[];
}
function renderTextChildren(children: TextChild[]): React.ReactNode {
return children.map((child, index) => {
if (child.type === "link") {
return (
<a
key={index}
href={child.url}
className="text-blue-600 hover:underline"
target="_blank"
rel="noopener noreferrer"
>
{child.children && renderTextChildren(child.children)}
</a>
);
}
let text: React.ReactNode = child.text || "";
if (child.bold) text = <strong key={index}>{text}</strong>;
if (child.italic) text = <em key={index}>{text}</em>;
if (child.underline) text = <u key={index}>{text}</u>;
if (child.strikethrough) text = <s key={index}>{text}</s>;
if (child.code) text = <code key={index} className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">{text}</code>;
return <span key={index}>{text}</span>;
});
}
export function BlocksRenderer({ blocks }: BlocksRendererProps) {
return (
<div className="prose prose-lg max-w-none rtl">
{blocks.map((block, index) => {
switch (block.type) {
case "paragraph":
return (
<p key={index}>
{block.children && renderTextChildren(block.children as TextChild[])}
</p>
);
case "heading":
const level = block.level || 2;
const HeadingTag = `h${level}` as keyof JSX.IntrinsicElements;
return (
<HeadingTag key={index}>
{block.children && renderTextChildren(block.children as TextChild[])}
</HeadingTag>
);
case "list":
const ListTag = (block as { format?: string }).format === "ordered" ? "ol" : "ul";
return (
<ListTag key={index}>
{block.children?.map((item, itemIndex) => (
<li key={itemIndex}>
{(item as TextChild).children && renderTextChildren((item as TextChild).children || [])}
</li>
))}
</ListTag>
);
case "quote":
return (
<blockquote key={index} className="border-r-4 border-blue-500 pr-4 my-4 italic text-gray-700">
{block.children && renderTextChildren(block.children as TextChild[])}
</blockquote>
);
case "code":
return (
<pre key={index} className="bg-gray-900 text-gray-100 rounded-lg p-4 overflow-x-auto">
<code className="text-sm font-mono">
{block.children && renderTextChildren(block.children as TextChild[])}
</code>
</pre>
);
case "image":
if (!block.image) return null;
const imgUrl = getStrapiImageUrl(block.image as StrapiImage, "large");
return (
<figure key={index} className="my-6">
<div className="relative w-full aspect-video rounded-lg overflow-hidden">
<Image
src={imgUrl}
alt={(block.image as StrapiImage).alternativeText || ""}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 800px"
/>
</div>
{(block.image as StrapiImage).alternativeText && (
<figcaption className="text-center text-sm text-gray-500 mt-2">
{(block.image as StrapiImage).alternativeText}
</figcaption>
)}
</figure>
);
default:
return null;
}
})}
</div>
);
}صفحة المقال الديناميكية
أنشئ src/app/articles/[slug]/page.tsx:
import { notFound } from "next/navigation";
import Image from "next/image";
import type { Metadata } from "next";
import {
getArticleBySlug,
getAllArticleSlugs,
getStrapiImageUrl,
} from "@/lib/strapi";
import { BlocksRenderer } from "@/components/BlocksRenderer";
interface ArticlePageProps {
params: Promise<{ slug: string }>;
}
// توليد الـ metadata ديناميكيًا لكل مقال
export async function generateMetadata({ params }: ArticlePageProps): Promise<Metadata> {
const { slug } = await params;
const article = await getArticleBySlug(slug);
if (!article) {
return { title: "المقال غير موجود" };
}
return {
title: article.title,
description: article.excerpt || undefined,
openGraph: {
title: article.title,
description: article.excerpt || undefined,
images: article.cover
? [getStrapiImageUrl(article.cover, "large")]
: [],
type: "article",
publishedTime: article.publishedAt || undefined,
},
};
}
// توليد المسارات الستاتيكية مسبقًا
export async function generateStaticParams() {
const slugs = await getAllArticleSlugs();
return slugs.map((slug) => ({ slug }));
}
export default async function ArticlePage({ params }: ArticlePageProps) {
const { slug } = await params;
const article = await getArticleBySlug(slug);
if (!article) {
notFound();
}
const coverUrl = getStrapiImageUrl(article.cover, "large");
const publishDate = article.publishedAt
? new Date(article.publishedAt).toLocaleDateString("ar-TN", {
year: "numeric",
month: "long",
day: "numeric",
})
: null;
return (
<main className="max-w-3xl mx-auto px-4 py-8">
{/* الفئة */}
{article.category && (
<div className="mb-4">
<span className="text-sm font-semibold text-blue-600 uppercase tracking-wider">
{article.category.name}
</span>
</div>
)}
{/* العنوان */}
<h1 className="text-4xl font-bold text-gray-900 mb-4 leading-tight">
{article.title}
</h1>
{/* المقتطف */}
{article.excerpt && (
<p className="text-xl text-gray-600 mb-6 leading-relaxed">
{article.excerpt}
</p>
)}
{/* معلومات المؤلف */}
<div className="flex items-center gap-4 mb-8 pb-8 border-b border-gray-200">
{article.author?.avatar && (
<Image
src={getStrapiImageUrl(article.author.avatar, "thumbnail")}
alt={article.author.name}
width={48}
height={48}
className="rounded-full object-cover"
/>
)}
<div>
{article.author && (
<p className="font-semibold text-gray-900">{article.author.name}</p>
)}
{publishDate && (
<p className="text-sm text-gray-500">{publishDate}</p>
)}
</div>
</div>
{/* صورة الغلاف */}
{article.cover && (
<div className="relative aspect-video rounded-xl overflow-hidden mb-8">
<Image
src={coverUrl}
alt={article.cover.alternativeText || article.title}
fill
priority
className="object-cover"
sizes="(max-width: 768px) 100vw, 800px"
/>
</div>
)}
{/* محتوى المقال */}
{article.content && (
<BlocksRenderer blocks={article.content} />
)}
</main>
);
}الخطوة 8: التنقل حسب الفئات
لقد أضفنا تصفية الفئات في الخطوة السادسة عبر searchParams. لنضف صفحة مخصصة لكل فئة:
أنشئ src/app/categories/[slug]/page.tsx:
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import {
getArticles,
getCategoryBySlug,
getCategories,
} from "@/lib/strapi";
import { ArticleCard } from "@/components/ArticleCard";
interface CategoryPageProps {
params: Promise<{ slug: string }>;
searchParams: Promise<{ page?: string }>;
}
export async function generateMetadata({ params }: CategoryPageProps): Promise<Metadata> {
const { slug } = await params;
const category = await getCategoryBySlug(slug);
if (!category) return { title: "الفئة غير موجودة" };
return {
title: `${category.name} — المدونة`,
description: category.description || `مقالات في فئة ${category.name}`,
};
}
export async function generateStaticParams() {
const categories = await getCategories();
return categories.map((cat) => ({ slug: cat.slug }));
}
export default async function CategoryPage({ params, searchParams }: CategoryPageProps) {
const { slug } = await params;
const resolvedSearch = await searchParams;
const page = Number(resolvedSearch.page) || 1;
const [category, articlesResponse] = await Promise.all([
getCategoryBySlug(slug),
getArticles({ categorySlug: slug, page, pageSize: 9 }),
]);
if (!category) notFound();
const { data: articles, meta } = articlesResponse;
return (
<main className="max-w-6xl mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold mb-2">{category.name}</h1>
{category.description && (
<p className="text-gray-600">{category.description}</p>
)}
<p className="text-sm text-gray-400 mt-2">
{meta.pagination?.total} مقالة
</p>
</div>
{articles.length === 0 ? (
<p className="text-center py-16 text-gray-500">لا توجد مقالات بعد</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{articles.map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
)}
</main>
);
}تلميح للمكونات القابلة لإعادة الاستخدام: استخرج مكون ArticleCard إلى ملف منفصل src/components/ArticleCard.tsx واستخدمه في كل من صفحة المقالات الرئيسية وصفحة الفئة.
الخطوة 9: معاينة المسودات (Draft Mode)
Next.js Draft Mode يتيح لك معاينة المسودات مباشرةً من Strapi.
Route Handler لتفعيل Draft Mode
أنشئ src/app/api/preview/route.ts:
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
import type { NextRequest } from "next/server";
const PREVIEW_SECRET = process.env.PREVIEW_SECRET;
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const secret = searchParams.get("secret");
const slug = searchParams.get("slug");
const type = searchParams.get("type") || "articles";
// التحقق من السر
if (!PREVIEW_SECRET || secret !== PREVIEW_SECRET) {
return new Response("رمز معاينة خاطئ", { status: 401 });
}
if (!slug) {
return new Response("معامل slug مفقود", { status: 400 });
}
const draft = await draftMode();
draft.enable();
// إعادة التوجيه إلى الصفحة المطلوبة
redirect(`/${type}/${slug}`);
}
// إيقاف تشغيل وضع المعاينة
export async function DELETE() {
const draft = await draftMode();
draft.disable();
return new Response("تم إيقاف وضع المعاينة");
}استخدام Draft Mode في صفحة المقال
عدّل src/app/articles/[slug]/page.tsx:
import { draftMode } from "next/headers";
export default async function ArticlePage({ params }: ArticlePageProps) {
const { slug } = await params;
const draft = await draftMode();
// جلب المسودة إذا كنا في وضع المعاينة
const article = await getArticleBySlug(slug, {
preview: draft.isEnabled,
});
if (!article) notFound();
return (
<main>
{draft.isEnabled && (
<div className="bg-yellow-100 border border-yellow-300 text-yellow-800 px-4 py-2 text-sm text-center">
وضع المعاينة مفعّل — هذا المحتوى غير منشور
<a href="/api/preview" className="mr-4 underline">إيقاف المعاينة</a>
</div>
)}
{/* باقي محتوى الصفحة */}
</main>
);
}أضف PREVIEW_SECRET إلى .env.local:
PREVIEW_SECRET=your_random_secret_here_change_in_productionلمعاينة مسودة، اذهب إلى:
http://localhost:3000/api/preview?secret=your_secret&slug=my-article-slug&type=articles
الخطوة 10: تعدد اللغات (i18n)
تفعيل i18n في Strapi
- في لوحة تحكم Strapi، اذهب إلى Settings → Internationalization
- انقر Add a locale وأضف:
ar(Arabic)fr(French)
- الـ locale الافتراضي:
en(English)
تفعيل i18n على أنواع المحتوى
- في Content-Type Builder، افتح نوع
Article - انقر على Edit وفعّل Internationalization
- كرر لأنواع
CategoryوAuthor
تحديث عميل API
// في src/lib/strapi.ts — تحديث دالة getArticles
export async function getArticles(params?: {
page?: number;
pageSize?: number;
categorySlug?: string;
locale?: string; // "en" | "ar" | "fr"
}): Promise<StrapiResponse<StrapiArticle[]>> {
const queryParams: Record<string, unknown> = {
...ARTICLE_POPULATE,
sort: ["publishedAt:desc"],
pagination: {
page: params?.page || 1,
pageSize: params?.pageSize || 10,
},
locale: params?.locale || "en",
};
if (params?.categorySlug) {
queryParams.filters = {
category: { slug: { $eq: params.categorySlug } },
};
}
return strapiRequest<StrapiArticle[]>("/articles", queryParams);
}بنية المسارات متعددة اللغات في Next.js
// src/app/[locale]/page.tsx
interface LocalizedHomeProps {
params: Promise<{ locale: string }>;
}
export default async function LocalizedHome({ params }: LocalizedHomeProps) {
const { locale } = await params;
const articlesResponse = await getArticles({ locale });
// ...
}
// src/app/[locale]/layout.tsx
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const dir = locale === "ar" ? "rtl" : "ltr";
return (
<html lang={locale} dir={dir}>
<body>{children}</body>
</html>
);
}الخطوة 11: تحسين الصور مع Next.js Image
إعداد Image Loader لـ Strapi
أنشئ src/lib/image-loader.ts:
import type { ImageLoaderProps } from "next/image";
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || "http://localhost:1337";
export default function strapiLoader({ src, width, quality }: ImageLoaderProps): string {
// إذا كان الـ URL مطلقًا بالفعل، أعده كما هو
if (src.startsWith("http")) return src;
// أضف الـ base URL لـ Strapi
return `${STRAPI_URL}${src}`;
}استخدام الصور المحسّنة
import Image from "next/image";
import strapiLoader from "@/lib/image-loader";
// في المكوّن
<Image
loader={strapiLoader}
src={article.cover.url}
alt={article.cover.alternativeText || article.title}
width={article.cover.width}
height={article.cover.height}
className="object-cover rounded-lg"
priority={false}
placeholder="blur"
blurDataURL="data:image/svg+xml;base64,..."
/>توليد Blur Placeholder
لتحسين تجربة التحميل، أضف helper لتوليد placeholder:
// في src/lib/strapi.ts
export function getBlurDataUrl(width = 8, height = 5): string {
// placeholder بسيط بتدرج رمادي
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">
<filter id="b"><feGaussianBlur stdDeviation="1"/></filter>
<rect width="${width}" height="${height}" fill="#e5e7eb" filter="url(#b)"/>
</svg>`;
const base64 = Buffer.from(svg).toString("base64");
return `data:image/svg+xml;base64,${base64}`;
}الخطوة 12: النشر في الإنتاج
نشر Strapi على Railway
Railway هو أسهل خيار لاستضافة Strapi:
# تثبيت Railway CLI
npm install -g @railway/cli
# تسجيل الدخول
railway login
# إنشاء مشروع جديد في مجلد Strapi
cd my-blog-cms
railway init
# إضافة قاعدة بيانات PostgreSQL
railway add postgresql
# النشر
railway upمتغيرات البيئة لـ Strapi في الإنتاج
في لوحة تحكم Railway، أضف المتغيرات التالية:
NODE_ENV=production
DATABASE_URL=${{PostgreSQL.DATABASE_URL}}
APP_KEYS=your_app_key_1,your_app_key_2
API_TOKEN_SALT=your_api_token_salt
ADMIN_JWT_SECRET=your_admin_jwt_secret
TRANSFER_TOKEN_SALT=your_transfer_token_salt
JWT_SECRET=your_jwt_secretلتوليد أسرار آمنة:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"نشر Next.js على Vercel
# تثبيت Vercel CLI
npm install -g vercel
# في مجلد Next.js
cd my-blog-frontend
vercel
# أجب على الأسئلة وانشرأضف متغيرات البيئة في لوحة تحكم Vercel:
NEXT_PUBLIC_STRAPI_URL=https://your-strapi-app.railway.app
STRAPI_API_TOKEN=your_production_api_token
PREVIEW_SECRET=your_secure_preview_secretإعداد CORS في Strapi للإنتاج
عدّل config/middlewares.ts في مشروع Strapi:
export default [
"strapi::logger",
"strapi::errors",
{
name: "strapi::security",
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
"connect-src": ["'self'", "https:"],
"img-src": ["'self'", "data:", "blob:", "https:"],
"media-src": ["'self'", "data:", "blob:", "https:"],
upgradeInsecureRequests: null,
},
},
},
},
{
name: "strapi::cors",
config: {
origin: [
"http://localhost:3000",
"https://your-nextjs-app.vercel.app",
],
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
headers: ["Content-Type", "Authorization", "Origin", "Accept"],
},
},
"strapi::poweredBy",
"strapi::query",
"strapi::body",
"strapi::session",
"strapi::favicon",
"strapi::public",
];استكشاف الأخطاء الشائعة
خطأ: "CORS blocked"
السبب: نسيت إضافة domain الواجهة الأمامية في إعداد CORS بـ Strapi.
الحل: تحقق من config/middlewares.ts وأضف URL الصحيح.
خطأ: "Failed to fetch" من Next.js
السبب: متغير NEXT_PUBLIC_STRAPI_URL غير محدد أو خاطئ.
الحل:
# تأكد أن Strapi يعمل
curl http://localhost:1337/api/articles
# تأكد من وجود الملف
cat .env.localصور لا تظهر (404)
السبب: لم يُضف domain الـ Strapi في remotePatterns بـ next.config.ts.
الحل: أضف hostname الخاص بـ Strapi في إعدادات images.
خطأ: "Forbidden (403)" عند جلب البيانات
السبب: صلاحيات Public role غير مفعّلة.
الحل:
- اذهب إلى Strapi → Settings → Roles → Public
- فعّل
findوfindOneلأنواع المحتوى المطلوبة - احفظ
بيانات populate ناقصة
السبب: نسيت إضافة populate للعلاقات.
الحل: تأكد من تضمين populate=* أو تحديد العلاقات صراحةً:
// خاطئ — يُعيد البيانات بدون العلاقات
const response = await strapiRequest("/articles");
// صحيح
const response = await strapiRequest("/articles", {
populate: { author: true, category: true, cover: true },
});أداء بطيء مع محتوى كثير
الحل: استخدم ISR (Incremental Static Regeneration):
// في page.tsx أو route.ts
export const revalidate = 3600; // إعادة التحقق كل ساعة
// أو للطلبات الفردية
const response = await fetch(url, {
next: { revalidate: 3600 },
});الخطوات التالية
بعد إتمام هذا الدليل، يمكنك توسيع مشروعك بـ:
1. إضافة GraphQL
Strapi يدعم GraphQL عبر إضافة رسمية:
cd my-blog-cms
npm install @strapi/plugin-graphqlثم في config/plugins.ts:
export default {
graphql: {
config: {
endpoint: "/graphql",
shadowCRUD: true,
playgroundAlways: false,
},
},
};2. Webhook للـ Revalidation
اجعل Next.js يُعيد بناء الصفحات عند تحديث المحتوى في Strapi:
// src/app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from "next/cache";
import type { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
const secret = request.headers.get("x-revalidate-secret");
if (secret !== process.env.REVALIDATE_SECRET) {
return new Response("غير مصرح", { status: 401 });
}
const body = await request.json();
// إعادة التحقق بناءً على نوع المحتوى
if (body.model === "article") {
revalidatePath("/");
revalidatePath("/articles/[slug]", "page");
}
if (body.model === "category") {
revalidatePath("/categories/[slug]", "page");
}
return new Response("تم التحديث بنجاح");
}في لوحة تحكم Strapi، أضف Webhook:
- URL:
https://your-nextjs-app.vercel.app/api/revalidate - Header:
x-revalidate-secret: your_secret
3. نظام بحث
// في src/lib/strapi.ts
export async function searchArticles(query: string): Promise<StrapiArticle[]> {
const response = await strapiRequest<StrapiArticle[]>("/articles", {
...ARTICLE_POPULATE,
filters: {
$or: [
{ title: { $containsi: query } },
{ excerpt: { $containsi: query } },
],
},
pagination: { pageSize: 10 },
});
return response.data;
}4. خلاصة RSS
// src/app/feed.xml/route.ts
import { getArticles } from "@/lib/strapi";
export async function GET() {
const { data: articles } = await getArticles({ pageSize: 20 });
const SITE_URL = "https://your-domain.com";
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>مدونتي</title>
<link>${SITE_URL}</link>
<description>آخر المقالات التقنية</description>
${articles
.map(
(article) => `
<item>
<title>${article.title}</title>
<link>${SITE_URL}/articles/${article.slug}</link>
<description>${article.excerpt || ""}</description>
<pubDate>${new Date(article.publishedAt || article.createdAt).toUTCString()}</pubDate>
</item>`
)
.join("")}
</channel>
</rss>`;
return new Response(rss, {
headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
});
}الخلاصة
لقد بنيت منصة مدونة كاملة باستخدام Strapi 5 و Next.js 15 App Router، وتعلّمت:
- Strapi 5 كـ headless CMS مع Content Types Builder، نظام صلاحيات مرن، ودعم i18n
- عميل API آمن الأنواع مع TypeScript وتجميع query strings معقدة
- Server Components لعرض المحتوى من جانب الخادم بأداء مثالي
- Draft Mode لمعاينة المسودات قبل النشر
- تحسين الصور مع Next.js Image ومصادر Strapi
- النشر الكامل على Railway + Vercel
هذه البنية قابلة للتوسع لأي نوع من المحتوى — متجر إلكتروني، موقع توثيق، بوابة إخبارية، أو أي تطبيق يحتاج إدارة محتوى احترافية مع واجهة أمامية حديثة.
هل تريد المزيد؟ يمكنك الاطلاع على توثيق Strapi الرسمي على docs.strapi.io وتوثيق Next.js على nextjs.org/docs للتعمق في الميزات المتقدمة مثل Strapi Plugins API وNext.js Parallel Routes.
ناقش مشروعك معنا
نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.
دعنا نجد أفضل الحلول لاحتياجاتك.
مقالات ذات صلة

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

بناء واجهة GraphQL آمنة الأنواع مع Next.js App Router و Yoga و Pothos
تعلم كيفية بناء واجهة GraphQL API آمنة الأنواع بالكامل باستخدام Next.js 15 App Router و GraphQL Yoga و Pothos schema builder. يغطي هذا الدليل العملي تصميم المخططات والاستعلامات والتحولات والمصادقة وعميل React باستخدام urql.

إضافة المصادقة لتطبيق Next.js 15 باستخدام Auth.js v5: البريد الإلكتروني وOAuth والتحكم بالأدوار
تعلم كيفية إضافة نظام مصادقة جاهز للإنتاج لتطبيق Next.js 15 باستخدام Auth.js v5. يغطي هذا الدليل الشامل تسجيل الدخول عبر Google OAuth وبيانات الاعتماد بالبريد الإلكتروني وكلمة المرور والمسارات المحمية والتحكم بالوصول حسب الأدوار.