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

Noqta Team
بواسطة Noqta Team ·

جاري تحميل مشغل تحويل النص إلى كلام الصوتي...

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 5Payload CMSDirectusSanityWordPress
المصدر المفتوحنعم كاملًانعمنعمجزئينعم
TypeScript أصلينعمنعم كاملًاجزئينعملا
لوحة تحكم مدمجةنعمنعمنعمنعمنعم
REST + GraphQLكلاهماكلاهماكلاهماGraphQLREST
الاستضافة الذاتيةنعمنعمنعممحدودنعم
سهولة الإعدادعاليةمتوسطةمتوسطةعاليةعالية
قاعدة البياناتمتعددةمتعددةمتعددةسحابية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:

  1. اذهب إلى Content-Type Builder
  2. انقر على Create new collection type
  3. أدخل الاسم: Author
  4. أضف الحقول التالية:
اسم الحقلالنوعالخصائص
nameTextمطلوب
bioRich Textاختياري
avatarMedia (صورة واحدة)اختياري
emailEmailمطلوب، فريد

انقر Save لحفظ النوع وإعادة تشغيل Strapi تلقائيًا.

إنشاء نوع Category

أنشئ collection type جديد باسم Category:

اسم الحقلالنوعالخصائص
nameTextمطلوب
slugUID (من name)مطلوب
descriptionTextاختياري

إنشاء نوع Article

أنشئ collection type باسم Article:

اسم الحقلالنوعالخصائص
titleTextمطلوب
slugUID (من title)مطلوب
contentRich Text (Blocks)مطلوب
excerptTextاختياري
coverMedia (صورة واحدة)اختياري
publishedAtDateاختياري
authorRelation → AuthorMany-to-one
categoryRelation → CategoryMany-to-one

Rich Text Blocks في Strapi 5: الإصدار الخامس يستخدم محرر Blocks الجديد بدلاً من Markdown. يُعيد البيانات كمصفوفة JSON منظمة أسهل للتصيير.

تفعيل Draft/Publish

في إعدادات نوع Article، فعّل خيار Draft & Publish. هذا يضيف حقل publishedAt تلقائيًا ويتيح نشر المقالات بشكل منفصل.


الخطوة 3: تكوين صلاحيات API

بعد تعريف أنواع المحتوى، يجب منح الإذن للوصول العام:

  1. اذهب إلى Settings → Users & Permissions Plugin → Roles
  2. انقر على دور Public
  3. في قسم Permissions:
    • Article: فعّل find و findOne
    • Category: فعّل find و findOne
    • Author: فعّل find و findOne
  4. انقر 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/qs

qs مكتبة ممتازة لبناء 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

  1. اذهب إلى Settings → API Tokens
  2. انقر Create new API Token
  3. أدخل الاسم: Next.js Frontend
  4. اختر النوع: Read-only
  5. انسخ الـ 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

  1. في لوحة تحكم Strapi، اذهب إلى Settings → Internationalization
  2. انقر Add a locale وأضف:
    • ar (Arabic)
    • fr (French)
  3. الـ locale الافتراضي: en (English)

تفعيل i18n على أنواع المحتوى

  1. في Content-Type Builder، افتح نوع Article
  2. انقر على Edit وفعّل Internationalization
  3. كرر لأنواع 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 غير مفعّلة.

الحل:

  1. اذهب إلى Strapi → Settings → Roles → Public
  2. فعّل find و findOne لأنواع المحتوى المطلوبة
  3. احفظ

بيانات 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.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على بناء تطبيق متكامل باستخدام Prisma ORM و Next.js 15 App Router.

ناقش مشروعك معنا

نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.

دعنا نجد أفضل الحلول لاحتياجاتك.

مقالات ذات صلة

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

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

30 د قراءة·

بناء واجهة GraphQL آمنة الأنواع مع Next.js App Router و Yoga و Pothos

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

30 د قراءة·

إضافة المصادقة لتطبيق Next.js 15 باستخدام Auth.js v5: البريد الإلكتروني وOAuth والتحكم بالأدوار

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

30 د قراءة·