إطار Next.js

توليد المواقع الثابتة (SSG)

40 دقيقة الدرس 9 من 40

توليد المواقع الثابتة (SSG) في Next.js

توليد المواقع الثابتة (SSG) هو واحدة من أقوى الميزات في Next.js، حيث يسمح لك بعرض الصفحات مسبقاً في وقت البناء. يستكشف هذا الدرس العرض الثابت والمعاملات الديناميكية والتجديد الثابت التدريجي (ISR) وإعادة التحقق عند الطلب.

ما هو SSG؟ توليد المواقع الثابتة ينشئ صفحات HTML في وقت البناء يمكن تخزينها مؤقتاً وتقديمها فوراً من CDN. ينتج عن هذا أسرع تحميلات صفحات ممكنة و SEO ممتاز.

العرض الثابت بشكل افتراضي

في Next.js App Router، يتم توليد الصفحات بشكل ثابت بشكل افتراضي ما لم تستخدم ميزات ديناميكية:

// app/about/page.tsx // هذه الصفحة يتم توليدها بشكل ثابت تلقائياً export default function AboutPage() { return ( <div> <h1>من نحن</h1> <p>نبني تطبيقات ويب رائعة.</p> </div> ); }

الصفحات مع جلب البيانات يتم أيضاً توليدها بشكل ثابت إذا لم تختر عدم التخزين المؤقت:

// app/posts/page.tsx // يتم توليدها بشكل ثابت في وقت البناء async function getPosts() { const res = await fetch('https://api.example.com/posts', { cache: 'force-cache' // السلوك الافتراضي }); return res.json(); } export default async function PostsPage() { const posts = await getPosts(); return ( <div> <h1>جميع المنشورات</h1> {posts.map(post => ( <article key={post.id}> <h2>{post.title}</h2> <p>{post.excerpt}</p> </article> ))} </div> ); }

المسارات الديناميكية مع generateStaticParams

للمسارات الديناميكية، استخدم generateStaticParams لتحديد الصفحات التي يتم عرضها مسبقاً في وقت البناء:

// app/posts/[id]/page.tsx async function getPost(id: string) { const res = await fetch(`https://api.example.com/posts/${id}`); return res.json(); } // توليد المعاملات الثابتة في وقت البناء export async function generateStaticParams() { const posts = await fetch('https://api.example.com/posts').then(res => res.json() ); // إرجاع مصفوفة من كائنات المعاملات return posts.map((post: any) => ({ id: post.id.toString() })); } // هذه الصفحة يتم توليدها بشكل ثابت لكل معرّف export default async function PostPage({ params }: { params: { id: string } }) { const post = await getPost(params.id); return ( <article> <h1>{post.title}</h1> <div>{post.content}</div> </article> ); }
مخرجات البناء: عندما تشغل `npm run build`، سيظهر Next.js المسارات التي يتم توليدها بشكل ثابت (○) وأيها ديناميكية (λ). انظر إلى مخرجات البناء للتحقق من توليد صفحاتك بشكل صحيح.

قطاعات ديناميكية متعددة

generateStaticParams يعمل مع قطاعات ديناميكية متعددة:

// app/blog/[category]/[slug]/page.tsx export async function generateStaticParams() { const posts = await fetch('https://api.example.com/posts').then(res => res.json() ); // إرجاع جميع التركيبات من الفئة والمعرّف return posts.map((post: any) => ({ category: post.category, slug: post.slug })); } export default async function PostPage({ params }: { params: { category: string; slug: string } }) { const post = await fetch( `https://api.example.com/posts/${params.category}/${params.slug}` ).then(res => res.json()); return ( <article> <h1>{post.title}</h1> <p>الفئة: {params.category}</p> <div>{post.content}</div> </article> ); }

المسارات الديناميكية المتداخلة

التعامل مع العلاقات بين الأب والطفل في المسارات الديناميكية:

// app/authors/[authorId]/posts/[postId]/page.tsx // توليد المعاملات للمسار الأب // app/authors/[authorId]/posts/page.tsx export async function generateStaticParams() { const authors = await fetch('https://api.example.com/authors').then(res => res.json() ); return authors.map((author: any) => ({ authorId: author.id.toString() })); } // توليد المعاملات للمسار الطفل // app/authors/[authorId]/posts/[postId]/page.tsx export async function generateStaticParams({ params: { authorId } }: { params: { authorId: string } }) { const posts = await fetch( `https://api.example.com/authors/${authorId}/posts` ).then(res => res.json()); return posts.map((post: any) => ({ postId: post.id.toString() })); } export default async function AuthorPostPage({ params }: { params: { authorId: string; postId: string } }) { const post = await fetch( `https://api.example.com/authors/${params.authorId}/posts/${params.postId}` ).then(res => res.json()); return ( <article> <h1>{post.title}</h1> <p>بواسطة المؤلف #{params.authorId}</p> <div>{post.content}</div> </article> ); }

التجديد الثابت التدريجي (ISR)

ISR يسمح لك بتحديث الصفحات الثابتة بعد وقت البناء دون إعادة بناء الموقع بأكمله:

// app/posts/[id]/page.tsx // إعادة التحقق من هذه الصفحة كل 60 ثانية export const revalidate = 60; async function getPost(id: string) { const res = await fetch(`https://api.example.com/posts/${id}`, { next: { revalidate: 60 } }); return res.json(); } export async function generateStaticParams() { const posts = await fetch('https://api.example.com/posts').then(res => res.json() ); return posts.map((post: any) => ({ id: post.id.toString() })); } export default async function PostPage({ params }: { params: { id: string } }) { const post = await getPost(params.id); return ( <article> <h1>{post.title}</h1> <p>آخر تحديث: {new Date(post.updatedAt).toLocaleString()}</p> <div>{post.content}</div> </article> ); }
كيف يعمل ISR:
  1. الطلب الأول: يخدم الصفحة المولدة بشكل ثابت من وقت البناء
  2. بعد فترة إعادة التحقق: يطلق الطلب التالي التجديد في الخلفية أثناء خدمة المحتوى القديم
  3. بمجرد اكتمال التجديد: يتم تخزين النسخة الجديدة مؤقتاً وتقديمها للطلبات اللاحقة
  4. إذا فشل التجديد: تستمر النسخة القديمة المخزنة في الخدمة

خيارات إعادة التحقق المستندة إلى الوقت

1. إعادة التحقق من قسم المسار

// app/blog/page.tsx // إعادة التحقق كل 30 دقيقة export const revalidate = 1800; export default async function BlogPage() { const posts = await fetch('https://api.example.com/posts').then(res => res.json() ); return ( <div> {posts.map(post => ( <article key={post.id}> <h2>{post.title}</h2> </article> ))} </div> ); }

2. إعادة التحقق على مستوى Fetch

// أوقات إعادة تحقق مختلفة لمصادر بيانات مختلفة async function getData() { // إعادة التحقق من المنشورات كل 60 ثانية const posts = await fetch('https://api.example.com/posts', { next: { revalidate: 60 } }); // إعادة التحقق من التعليقات كل 30 ثانية const comments = await fetch('https://api.example.com/comments', { next: { revalidate: 30 } }); return { posts: await posts.json(), comments: await comments.json() }; } export default async function Page() { const { posts, comments } = await getData(); return ( <div> <h1>المنشورات</h1> {/* عرض المنشورات والتعليقات */} </div> ); }

3. تعطيل إعادة التحقق

// عدم إعادة التحقق أبداً - توليد ثابت بحت export const revalidate = false; // أو استخدام Infinity export const revalidate = Infinity;

إعادة التحقق عند الطلب

تشغيل إعادة التحقق يدوياً باستخدام revalidatePath() أو revalidateTag():

// app/api/revalidate/route.ts import { revalidatePath, revalidateTag } from 'next/cache'; import { NextRequest } from 'next/server'; export async function POST(request: NextRequest) { const secret = request.nextUrl.searchParams.get('secret'); // التحقق من الرمز السري if (secret !== process.env.REVALIDATE_SECRET) { return Response.json({ message: 'رمز غير صالح' }, { status: 401 }); } const path = request.nextUrl.searchParams.get('path'); const tag = request.nextUrl.searchParams.get('tag'); try { if (path) { // إعادة التحقق من مسار معين revalidatePath(path); return Response.json({ revalidated: true, path, now: Date.now() }); } if (tag) { // إعادة التحقق بواسطة العلامة revalidateTag(tag); return Response.json({ revalidated: true, tag, now: Date.now() }); } return Response.json( { message: 'مسار أو علامة مفقودة' }, { status: 400 } ); } catch (err) { return Response.json( { message: 'خطأ في إعادة التحقق' }, { status: 500 } ); } }

استخدام علامات التخزين المؤقت للتحكم الدقيق:

// app/posts/[id]/page.tsx async function getPost(id: string) { const res = await fetch(`https://api.example.com/posts/${id}`, { next: { tags: ['posts', `post-${id}`] } }); return res.json(); } export default async function PostPage({ params }: { params: { id: string } }) { const post = await getPost(params.id); return ( <article> <h1>{post.title}</h1> <div>{post.content}</div> </article> ); } // تشغيل إعادة التحقق: // POST /api/revalidate?secret=xxx&tag=post-123 // هذا يعيد التحقق فقط من المنشور بمعرّف 123 // POST /api/revalidate?secret=xxx&tag=posts // هذا يعيد التحقق من جميع المنشورات
تكامل Webhook: اربط webhook الخاص بـ CMS بنقطة نهاية API لإعادة التحقق. عندما يتم تحديث المحتوى في CMS الخاص بك، يشغل تلقائياً إعادة التحقق من الصفحات المتأثرة.

generateStaticParams مع التصفح الصفحي

توليد صفحات ثابتة للمحتوى المقسم إلى صفحات:

// app/posts/page/[pageNumber]/page.tsx const POSTS_PER_PAGE = 10; export async function generateStaticParams() { const totalPosts = await fetch('https://api.example.com/posts/count') .then(res => res.json()); const totalPages = Math.ceil(totalPosts / POSTS_PER_PAGE); // توليد الصفحات من 1 إلى totalPages return Array.from({ length: totalPages }, (_, i) => ({ pageNumber: (i + 1).toString() })); } async function getPosts(page: number) { const res = await fetch( `https://api.example.com/posts?page=${page}&limit=${POSTS_PER_PAGE}` ); return res.json(); } export default async function PostsPage({ params }: { params: { pageNumber: string } }) { const page = parseInt(params.pageNumber); const posts = await getPosts(page); return ( <div> <h1>المنشورات - الصفحة {page}</h1> {posts.map(post => ( <article key={post.id}> <h2>{post.title}</h2> </article> ))} <nav> {page > 1 && ( <a href={`/posts/page/${page - 1}`}>السابق</a> )} <a href={`/posts/page/${page + 1}`}>التالي</a> </nav> </div> ); }

التعامل مع الصفحات المفقودة

التحكم في ما يحدث عندما لا يكون لمسار ديناميكي صفحة مولدة مسبقاً:

// app/posts/[id]/page.tsx // توليد فقط أول 100 منشور في وقت البناء export async function generateStaticParams() { const posts = await fetch('https://api.example.com/posts?limit=100') .then(res => res.json()); return posts.map((post: any) => ({ id: post.id.toString() })); } // تكوين ما يحدث للصفحات غير المولدة export const dynamicParams = true; // افتراضي - توليد عند الطلب // export const dynamicParams = false; // إرجاع 404 للصفحات غير المولدة export default async function PostPage({ params }: { params: { id: string } }) { // سيتم استدعاء هذا للصفحات غير المولدة إذا كان dynamicParams = true const post = await fetch(`https://api.example.com/posts/${params.id}`) .then(res => { if (!res.ok) return null; return res.json(); }); if (!post) { return ( <div> <h1>المنشور غير موجود</h1> </div> ); } return ( <article> <h1>{post.title}</h1> <div>{post.content}</div> </article> ); }
خيارات dynamicParams:
  • true (افتراضي): الصفحات غير المولدة يتم عرضها عند الطلب وتخزينها مؤقتاً
  • false: الصفحات غير المولدة ترجع 404

توليد البيانات الوصفية للصفحات الثابتة

توليد البيانات الوصفية ديناميكياً لكل صفحة ثابتة:

// app/posts/[id]/page.tsx import { Metadata } from 'next'; async function getPost(id: string) { const res = await fetch(`https://api.example.com/posts/${id}`); return res.json(); } // توليد البيانات الوصفية لكل صفحة export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> { const post = await getPost(params.id); return { title: post.title, description: post.excerpt, openGraph: { title: post.title, description: post.excerpt, images: [post.featuredImage], type: 'article', publishedTime: post.publishedAt, authors: [post.author.name] }, twitter: { card: 'summary_large_image', title: post.title, description: post.excerpt, images: [post.featuredImage] } }; } export async function generateStaticParams() { const posts = await fetch('https://api.example.com/posts') .then(res => res.json()); return posts.map((post: any) => ({ id: post.id.toString() })); } export default async function PostPage({ params }: { params: { id: string } }) { const post = await getPost(params.id); return ( <article> <h1>{post.title}</h1> <div>{post.content}</div> </article> ); }

تحسين أداء البناء

المواقع الكبيرة: توليد آلاف الصفحات في وقت البناء يمكن أن يكون بطيئاً. ضع في اعتبارك هذه الاستراتيجيات:
  • توليد فقط الصفحات الشائعة في وقت البناء
  • استخدام ISR للمحتوى الذي يتم الوصول إليه بشكل أقل تكراراً
  • تعيين dynamicParams = true لتوليد صفحات أخرى عند الطلب
  • استخدام البناء المتوازي في خط CI/CD الخاص بك
// الاستراتيجية: توليد أول 1000 منشور، عرض الآخرين عند الطلب export async function generateStaticParams() { // توليد فقط المنشورات الأكثر شعبية const posts = await fetch( 'https://api.example.com/posts?sortBy=views&limit=1000' ).then(res => res.json()); return posts.map((post: any) => ({ id: post.id.toString() })); } // السماح بالتوليد عند الطلب لمنشورات أخرى export const dynamicParams = true; // تخزين الصفحات عند الطلب لمدة ساعة واحدة export const revalidate = 3600;

تمرين عملي

المهمة: أنشئ مدونة مع التوليد الثابت:

  1. أنشئ صفحة قائمة منشورات يتم توليدها بشكل ثابت
  2. نفذ صفحات منشورات ديناميكية باستخدام generateStaticParams
  3. أضف تصفح صفحي مع 10 منشورات لكل صفحة (توليد أول 5 صفحات)
  4. نفذ ISR مع إعادة تحقق 5 دقائق لصفحات المنشورات
  5. أنشئ مسار API لإعادة التحقق عند الطلب
  6. أضف علامات تخزين مؤقت للتحكم الدقيق في إعادة التحقق
  7. وليد البيانات الوصفية الصحيحة لكل صفحة منشور

المتطلبات:

  • استخدم TypeScript مع الأنواع الصحيحة
  • نفذ معالجة الأخطاء للمنشورات المفقودة
  • عيّن dynamicParams = true للمنشورات غير الموجودة في أول 100
  • أضف البيانات الوصفية الصحيحة لـ SEO لجميع الصفحات
  • تحقق من أن مخرجات البناء تظهر علامات التوليد الثابت (○)

التحدي الإضافي: نفذ صفحات الفئات مع generateStaticParams التي تولد مسبقاً جميع تركيبات الفئة/المنشور، وأضف نقطة نهاية webhook لتشغيل إعادة التحقق عندما يتم تحديث المنشورات.

الملخص

  • توليد المواقع الثابتة يعرض الصفحات مسبقاً في وقت البناء لأقصى أداء
  • الصفحات ثابتة بشكل افتراضي في App Router ما لم تختر عدم ذلك
  • استخدم generateStaticParams لتحديد المسارات الديناميكية لعرضها مسبقاً
  • ISR يسمح بتحديث الصفحات الثابتة دون إعادة البناء الكاملة باستخدام revalidate
  • إعادة التحقق المستندة إلى الوقت يمكن تعيينها على مستوى المسار أو fetch
  • إعادة التحقق عند الطلب تستخدم revalidatePath() و revalidateTag()
  • علامات التخزين المؤقت تمكن التحكم الدقيق في الصفحات التي يتم إعادة التحقق منها
  • dynamicParams يتحكم في السلوك للصفحات غير المولدة
  • توليد البيانات الوصفية ديناميكياً لكل صفحة ثابتة باستخدام generateMetadata
  • تحسين أداء البناء من خلال توليد الصفحات الشائعة فقط في وقت البناء