Next.js 15 Partial Prerendering (PPR): بناء لوحة تحكم فائقة السرعة مع العرض الهجين

سرعة ثابتة. قوة ديناميكية. صفحة واحدة. يُعد Partial Prerendering (PPR) في Next.js 15 أهم ابتكار في العرض منذ Server Components. في هذا الدليل، ستبني لوحة تحكم تحليلية تُحمّل فوريًا بقالب ثابت بينما يتدفق المحتوى الشخصي والبيانات الحية — كل ذلك بدون شلالات JavaScript على جانب العميل.
ما ستتعلمه
بنهاية هذا الدليل، ستكون قادرًا على:
- فهم ما هو PPR وكيف يختلف عن SSR وSSG وISR
- تفعيل PPR في مشروع Next.js 15 باستخدام الخاصية التجريبية
- بناء قالب ثابت يُحمّل في أجزاء من الثانية من شبكة CDN
- استخدام حدود React Suspense لتحديد "فجوات" ديناميكية في الصفحات الثابتة
- بث محتوى مخصص (بيانات المستخدم، مقاييس حية) في تلك الفجوات
- تنفيذ حالات تحميل احتياطية لكل قسم ديناميكي
- قياس مكاسب الأداء الحقيقية باستخدام Core Web Vitals
- نشر تطبيق PPR على Vercel مع التخزين المؤقت على الحافة
المتطلبات الأساسية
قبل البدء، تأكد من توفر:
- Node.js 20+ مثبت (
node --version) - إلمام بـ Next.js 15 (App Router، Server Components، التخطيطات)
- أساسيات React 19 (Suspense، المكونات غير المتزامنة)
- خبرة في TypeScript
- محرر أكواد — يُنصح بـ VS Code أو Cursor
فهم Partial Prerendering
مشكلة العرض
استراتيجيات العرض التقليدية تجبرك على الاختيار بين السرعة والديناميكية لصفحة كاملة:
| الاستراتيجية | السرعة | البيانات الحية | التخصيص |
|---|---|---|---|
| SSG (ثابت) | فوري | لا | لا |
| SSR (خادم) | أبطأ | نعم | نعم |
| ISR (تدريجي) | سريع | بيانات قديمة | لا |
| CSR (عميل) | بطيء مبدئيًا | نعم | نعم |
معظم الصفحات الحقيقية تحتوي على أجزاء ثابتة وديناميكية معًا. لوحة التحكم بها شريط تنقل ثابت وتخطيط ثابت وتسميات ثابتة — لكن الرسوم البيانية وتحية المستخدم والمقاييس الحية ديناميكية. قبل PPR، كان عليك عرض الصفحة بأكملها ديناميكيًا لمجرد أن قسمًا واحدًا يحتاج بيانات حديثة.
كيف يحل PPR هذه المشكلة
يتيح لك Partial Prerendering العرض المسبق للأجزاء الثابتة من الصفحة وقت البناء مع ترك "فجوات" ديناميكية تُملأ وقت الطلب عبر البث.
إليك ما يحدث عندما يطلب المستخدم صفحة PPR:
- تقدم شبكة CDN فورًا قالب HTML الثابت (التنقل، التخطيط، العناوين، حالات التحميل)
- يعرض المتصفح هذا القالب فورًا — يرى المستخدم المحتوى في أجزاء من الثانية
- يقوم الخادم ببث المحتوى الديناميكي في حدود Suspense عند حل كل جزء
- تمتلئ الصفحة تدريجيًا بدون أي حالة تحميل كاملة
النتيجة: TTFB لصفحة ثابتة مع حداثة صفحة ديناميكية.
PPR مقابل Streaming SSR التقليدي
قد تتساءل: "كيف يختلف هذا عن البث العادي مع Suspense؟"
مع Streaming SSR التقليدي، يتم عرض الصفحة بأكملها عند الطلب في وقت التشغيل. يرسل الخادم القالب ويبث الأجزاء، لكن لا شيء يُعرض مسبقًا — لا يزال TTFB يعتمد على وقت استجابة الخادم.
مع PPR، يتم عرض القالب الثابت مسبقًا وتخزينه مؤقتًا على الحافة. فقط الفجوات الديناميكية تحتاج حسابات الخادم. هذا يعني:
- القالب الثابت يأتي من CDN (5-20ms) بدلاً من الخادم الأصلي (50-300ms)
- الأجزاء الثابتة مضمونة الاتساق — بدون تفاوت في حسابات الخادم
- الأجزاء الديناميكية تتدفق بشكل مستقل، فاستعلام قاعدة بيانات بطيء لا يحظر الصفحة كلها
الخطوة 1: إنشاء المشروع
ابدأ بإنشاء مشروع Next.js 15 جديد:
npx create-next-app@latest ppr-dashboard --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd ppr-dashboardتحقق من إصدار Next.js:
npx next --versionالخطوة 2: تفعيل Partial Prerendering
PPR هي ميزة تجريبية في Next.js 15. فعّلها في next.config.ts:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
ppr: true,
},
};
export default nextConfig;هذا كل ما يلزم لتفعيل PPR عالميًا. بمجرد التفعيل، سيقوم Next.js تلقائيًا بالعرض المسبق للأجزاء الثابتة من كل صفحة وترك حدود Suspense كفجوات ديناميكية.
مهم: يتطلب PPR استخدام App Router. لا يعمل مع Pages Router. تأكد من أن جميع صفحاتك تستخدم مجلد app/.
الخطوة 3: فهم الحدود بين الثابت والديناميكي
ثابت افتراضيًا
في Next.js 15 مع PPR، كل شيء ثابت ما لم يختار العرض الديناميكي. يصبح المكون ديناميكيًا عندما:
- يستدعي
cookies()أوheaders() - يستخدم
searchParams - يستدعي
fetch()معcache: "no-store"أوnext: { revalidate: 0 } - يستخدم
connection()(بديلunstable_noStore()في Next.js 15)
قاعدة حدود Suspense
يجب لف المكون الديناميكي في حدود <Suspense>. هذه الحدود تخبر PPR: "اعرض مسبقًا كل شيء خارج هذه الحدود، وابث هذا الجزء وقت الطلب."
// هذا التخطيط ثابت — يُعرض مسبقًا وقت البناء
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex">
<Sidebar /> {/* ثابت — يُعرض مسبقًا */}
<main>{children}</main>
</div>
);
}
// هذه الصفحة تحتوي على أجزاء ثابتة وديناميكية
export default function Page() {
return (
<div>
<h1>لوحة التحكم</h1> {/* ثابت — يُعرض مسبقًا */}
<StaticChart /> {/* ثابت — يُعرض مسبقًا */}
<Suspense fallback={<MetricsSkeleton />}>
<LiveMetrics /> {/* ديناميكي — يُبث */}
</Suspense>
</div>
);
}الخطوة 4: بناء طبقة البيانات
أنشئ طبقة بيانات محاكاة تحاكي استدعاءات API حقيقية مع تأخيرات واقعية. في الإنتاج، ستكون هذه استعلامات قاعدة البيانات أو استدعاءات API.
// src/lib/data.ts
// محاكاة تأخير الشبكة
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export type Metric = {
label: string;
value: string;
change: number;
trend: "up" | "down" | "flat";
};
export type ChartDataPoint = {
date: string;
visitors: number;
pageViews: number;
bounceRate: number;
};
export type Activity = {
id: string;
user: string;
action: string;
timestamp: string;
avatar: string;
};
// ثابت: لا يتغير بين الطلبات
export function getStaticMetadata() {
return {
dashboardName: "نظرة عامة على التحليلات",
version: "2.4.1",
lastDeployment: "2026-03-20",
sections: ["المقاييس", "الزيارات", "النشاط", "الأداء"],
};
}
// ديناميكي: محاكاة جلب مقاييس KPI الحية (سريع: ~200ms)
export async function getLiveMetrics(): Promise<Metric[]> {
await delay(200);
return [
{
label: "إجمالي الزوار",
value: (Math.floor(Math.random() * 50000) + 10000).toLocaleString(),
change: +(Math.random() * 20 - 5).toFixed(1),
trend: Math.random() > 0.3 ? "up" : "down",
},
{
label: "مشاهدات الصفحة",
value: (Math.floor(Math.random() * 150000) + 30000).toLocaleString(),
change: +(Math.random() * 15 - 3).toFixed(1),
trend: Math.random() > 0.4 ? "up" : "down",
},
{
label: "معدل الارتداد",
value: (Math.random() * 30 + 20).toFixed(1) + "%",
change: +(Math.random() * 5 - 8).toFixed(1),
trend: Math.random() > 0.5 ? "down" : "up",
},
{
label: "متوسط الجلسة",
value: Math.floor(Math.random() * 5 + 2) + "د " + Math.floor(Math.random() * 59) + "ث",
change: +(Math.random() * 10 - 2).toFixed(1),
trend: "up",
},
];
}
// ديناميكي: محاكاة جلب بيانات الرسم البياني (متوسط: ~500ms)
export async function getTrafficData(): Promise<ChartDataPoint[]> {
await delay(500);
const days = 14;
const data: ChartDataPoint[] = [];
for (let i = days; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
data.push({
date: date.toISOString().split("T")[0],
visitors: Math.floor(Math.random() * 3000) + 1000,
pageViews: Math.floor(Math.random() * 8000) + 2000,
bounceRate: +(Math.random() * 20 + 25).toFixed(1),
});
}
return data;
}
// ديناميكي: محاكاة جلب النشاط الأخير (بطيء: ~800ms)
export async function getRecentActivity(): Promise<Activity[]> {
await delay(800);
const actions = [
"سجّل حسابًا",
"اشترى خطة Pro",
"أرسل نموذجًا",
"ترك تقييمًا",
"رقّى حسابه",
"دعا عضو فريق",
"صدّر البيانات",
"أنشأ مشروعًا",
];
const names = [
"سارة أحمد", "محمد علي", "عائشة حسن",
"كارلوس ريفيرا", "إيمان محمود", "عمر حسان",
"يوكي تاناكا", "فاطمة الرشيد",
];
return Array.from({ length: 8 }, (_, i) => ({
id: `act-${i}`,
user: names[i],
action: actions[i],
timestamp: `منذ ${Math.floor(Math.random() * 59) + 1} دقيقة`,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${names[i]}`,
}));
}
// ديناميكي: محاكاة تحية مخصصة للمستخدم (سريع: ~100ms)
export async function getUserGreeting(): Promise<{
name: string;
role: string;
notifications: number;
}> {
await delay(100);
return {
name: "أليكس",
role: "مدير",
notifications: Math.floor(Math.random() * 12),
};
}لاحظ كيف أن لكل دالة تأخيرًا مختلفًا. هذا مقصود — في التطبيق الحقيقي، لمصادر البيانات المختلفة زمن استجابة مختلف. يتعامل PPR مع هذا بسلاسة لأن كل حدود Suspense تُحل بشكل مستقل.
الخطوة 5: إنشاء مكونات الهيكل العظمي
ابنِ هياكل تحميل تُعرض أثناء بث المحتوى الديناميكي. هذه هي العناصر الاحتياطية التي يراها المستخدمون في القالب الثابت.
// src/components/skeletons.tsx
export function MetricsSkeleton() {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="rounded-xl border border-gray-200 bg-white p-6 animate-pulse"
>
<div className="h-4 w-24 rounded bg-gray-200 mb-3" />
<div className="h-8 w-32 rounded bg-gray-200 mb-2" />
<div className="h-3 w-16 rounded bg-gray-200" />
</div>
))}
</div>
);
}
export function ChartSkeleton() {
return (
<div className="rounded-xl border border-gray-200 bg-white p-6 animate-pulse">
<div className="h-5 w-40 rounded bg-gray-200 mb-6" />
<div className="flex items-end gap-2 h-64">
{Array.from({ length: 14 }).map((_, i) => (
<div
key={i}
className="flex-1 rounded-t bg-gray-200"
style={{ height: `${Math.random() * 80 + 20}%` }}
/>
))}
</div>
</div>
);
}
export function ActivitySkeleton() {
return (
<div className="rounded-xl border border-gray-200 bg-white p-6 animate-pulse">
<div className="h-5 w-36 rounded bg-gray-200 mb-6" />
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-gray-200" />
<div className="flex-1">
<div className="h-4 w-48 rounded bg-gray-200 mb-1" />
<div className="h-3 w-20 rounded bg-gray-200" />
</div>
</div>
))}
</div>
</div>
);
}
export function GreetingSkeleton() {
return (
<div className="flex items-center gap-3 animate-pulse">
<div className="h-10 w-10 rounded-full bg-gray-200" />
<div>
<div className="h-5 w-32 rounded bg-gray-200 mb-1" />
<div className="h-3 w-20 rounded bg-gray-200" />
</div>
</div>
);
}نصيحة تصميمية: الهياكل العظمية الجيدة تطابق تمامًا تخطيط المحتوى الذي تستبدله. هذا يمنع انزياح التخطيط (CLS) عندما يتدفق المحتوى الديناميكي، وهو أمر حاسم لـ Core Web Vitals.
الخطوة 6: بناء المكونات الديناميكية
الآن أنشئ مكونات Server Components غير المتزامنة التي تجلب البيانات الديناميكية. ستُلف هذه المكونات في حدود Suspense.
تحية المستخدم
// src/components/user-greeting.tsx
import { getUserGreeting } from "@/lib/data";
export async function UserGreeting() {
const user = await getUserGreeting();
return (
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-semibold">
{user.name[0]}
</div>
<div>
<p className="font-semibold text-gray-900">
مرحبًا بعودتك، {user.name}
</p>
<p className="text-sm text-gray-500">{user.role}</p>
</div>
{user.notifications > 0 && (
<span className="ml-2 inline-flex items-center justify-center h-6 w-6 rounded-full bg-red-500 text-white text-xs font-bold">
{user.notifications}
</span>
)}
</div>
);
}بطاقات المقاييس الحية
// src/components/live-metrics.tsx
import { getLiveMetrics } from "@/lib/data";
import type { Metric } from "@/lib/data";
function MetricCard({ metric }: { metric: Metric }) {
const isPositive =
(metric.trend === "up" && metric.label !== "معدل الارتداد") ||
(metric.trend === "down" && metric.label === "معدل الارتداد");
return (
<div className="rounded-xl border border-gray-200 bg-white p-6 hover:shadow-md transition-shadow">
<p className="text-sm font-medium text-gray-500">{metric.label}</p>
<p className="mt-2 text-3xl font-bold text-gray-900">{metric.value}</p>
<div className="mt-2 flex items-center gap-1">
<span
className={`text-sm font-medium ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{metric.trend === "up" ? "↑" : "↓"} {Math.abs(metric.change)}%
</span>
<span className="text-sm text-gray-400">مقارنة بالأسبوع الماضي</span>
</div>
</div>
);
}
export async function LiveMetrics() {
const metrics = await getLiveMetrics();
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{metrics.map((metric) => (
<MetricCard key={metric.label} metric={metric} />
))}
</div>
);
}رسم بياني للزيارات
// src/components/traffic-chart.tsx
import { getTrafficData } from "@/lib/data";
export async function TrafficChart() {
const data = await getTrafficData();
const maxVisitors = Math.max(...data.map((d) => d.visitors));
return (
<div className="rounded-xl border border-gray-200 bg-white p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900">
نظرة عامة على الزيارات
</h3>
<span className="text-sm text-gray-500">آخر 14 يومًا</span>
</div>
<div className="flex items-end gap-1.5 h-64">
{data.map((point) => {
const height = (point.visitors / maxVisitors) * 100;
return (
<div
key={point.date}
className="group relative flex-1 flex flex-col items-center"
>
<div className="absolute -top-10 hidden group-hover:block bg-gray-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap">
{point.visitors.toLocaleString()} زائر
</div>
<div
className="w-full rounded-t bg-gradient-to-t from-blue-600 to-blue-400 hover:from-blue-700 hover:to-blue-500 transition-colors cursor-pointer"
style={{ height: `${height}%` }}
/>
<span className="mt-2 text-[10px] text-gray-400">
{new Date(point.date).getDate()}
</span>
</div>
);
})}
</div>
</div>
);
}موجز النشاط الأخير
// src/components/recent-activity.tsx
import { getRecentActivity } from "@/lib/data";
export async function RecentActivity() {
const activities = await getRecentActivity();
return (
<div className="rounded-xl border border-gray-200 bg-white p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">
النشاط الأخير
</h3>
<div className="space-y-4">
{activities.map((activity) => (
<div key={activity.id} className="flex items-center gap-3">
<img
src={activity.avatar}
alt={activity.user}
className="h-10 w-10 rounded-full bg-gray-100"
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-900">
<span className="font-medium">{activity.user}</span>{" "}
{activity.action}
</p>
<p className="text-xs text-gray-500">{activity.timestamp}</p>
</div>
</div>
))}
</div>
</div>
);
}الخطوة 7: تجميع صفحة لوحة التحكم
الآن اجمع كل شيء معًا. هنا يحدث سحر PPR — تدمج العناصر الثابتة مع المكونات الديناميكية الملفوفة بـ Suspense في صفحة واحدة.
// src/app/page.tsx
import { Suspense } from "react";
import { getStaticMetadata } from "@/lib/data";
import { UserGreeting } from "@/components/user-greeting";
import { LiveMetrics } from "@/components/live-metrics";
import { TrafficChart } from "@/components/traffic-chart";
import { RecentActivity } from "@/components/recent-activity";
import {
MetricsSkeleton,
ChartSkeleton,
ActivitySkeleton,
GreetingSkeleton,
} from "@/components/skeletons";
export default function DashboardPage() {
// يعمل وقت البناء — ثابت تمامًا
const metadata = getStaticMetadata();
return (
<div className="min-h-screen bg-gray-50">
{/* ثابت: شريط التنقل — يُعرض مسبقًا وقت البناء */}
<header className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-gradient-to-br from-blue-600 to-purple-600" />
<h1 className="text-xl font-bold text-gray-900">
{metadata.dashboardName}
</h1>
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
v{metadata.version}
</span>
</div>
{/* ديناميكي: تحية المستخدم — تُبث وقت الطلب */}
<Suspense fallback={<GreetingSkeleton />}>
<UserGreeting />
</Suspense>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
{/* ثابت: تبويبات الأقسام — تُعرض مسبقًا */}
<nav className="flex gap-1 bg-gray-100 p-1 rounded-lg w-fit">
{metadata.sections.map((section) => (
<button
key={section}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
section === "المقاييس"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-500 hover:text-gray-900"
}`}
>
{section}
</button>
))}
</nav>
{/* ديناميكي: بطاقات المقاييس الحية — تُبث (~200ms) */}
<section>
<h2 className="text-lg font-semibold text-gray-900 mb-4">
المقاييس الرئيسية
</h2>
<Suspense fallback={<MetricsSkeleton />}>
<LiveMetrics />
</Suspense>
</section>
{/* تخطيط مع الرسم البياني والنشاط جنبًا إلى جنب */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* ديناميكي: رسم بياني للزيارات — يُبث (~500ms) */}
<div className="lg:col-span-2">
<Suspense fallback={<ChartSkeleton />}>
<TrafficChart />
</Suspense>
</div>
{/* ديناميكي: النشاط الأخير — يُبث (~800ms) */}
<div>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
</div>
{/* ثابت: التذييل — يُعرض مسبقًا */}
<footer className="text-center text-sm text-gray-400 pt-8 border-t border-gray-200">
آخر نشر: {metadata.lastDeployment} · بُني باستخدام Next.js 15 PPR
</footer>
</main>
</div>
);
}ما يحدث وقت البناء
عند تشغيل next build، يحلل Next.js هذه الصفحة وينتج:
- HTML ثابت لـ: الرأس، تبويبات التنقل، عناوين الأقسام، التذييل، وجميع الهياكل العظمية الاحتياطية
- فجوات ديناميكية لـ:
UserGreeting،LiveMetrics،TrafficChart،RecentActivity
يُخزن HTML الثابت مؤقتًا على CDN. عندما يزور المستخدم الصفحة:
- 0ms: CDN تقدم القالب الثابت — يرى المستخدم فورًا لوحة تحكم كاملة التخطيط مع هياكل تحميل
- ~100ms: تحية المستخدم تتدفق وتستبدل
GreetingSkeleton - ~200ms: بطاقات المقاييس تتدفق وتستبدل
MetricsSkeleton - ~500ms: رسم الزيارات يتدفق ويستبدل
ChartSkeleton - ~800ms: موجز النشاط يتدفق ويستبدل
ActivitySkeleton
كل قسم يظهر بشكل مستقل عند حل بياناته. بدون شلالات. بدون حظر.
الخطوة 8: تكوين PPR لكل مسار
أحيانًا تريد PPR على مسارات محددة بدلاً من عالميًا. يدعم Next.js 15 التكوين لكل مسار باستخدام experimental_ppr:
// src/app/settings/page.tsx
// تفعيل PPR لهذه الصفحة تحديدًا
export const experimental_ppr = true;
export default function SettingsPage() {
return (
<div>
<h1>الإعدادات</h1>
<Suspense fallback={<div>جاري تحميل التفضيلات...</div>}>
<UserPreferences />
</Suspense>
</div>
);
}الخطوة 9: التعامل مع أنماط البيانات الديناميكية
استخدام connection() للاشتراك الديناميكي
في Next.js 15، الطريقة الموصى بها لوسم مكون كديناميكي هي دالة connection():
// src/components/server-time.tsx
import { connection } from "next/server";
export async function ServerTime() {
// هذا يخبر Next.js: "هذا المكون يحتاج بيانات حديثة كل طلب"
await connection();
const now = new Date();
return (
<p className="text-sm text-gray-500">
وقت الخادم: {now.toLocaleTimeString("ar-SA")}
</p>
);
}قراءة الكوكيز والرؤوس
المكونات التي تقرأ الكوكيز أو الرؤوس تصبح ديناميكية تلقائيًا:
// src/components/theme-aware-panel.tsx
import { cookies } from "next/headers";
export async function ThemeAwarePanel() {
const cookieStore = await cookies();
const theme = cookieStore.get("theme")?.value ?? "light";
return (
<div className={theme === "dark" ? "bg-gray-900 text-white" : "bg-white text-gray-900"}>
<p>المظهر المفضل لديك: {theme === "dark" ? "داكن" : "فاتح"}</p>
</div>
);
}الخطوة 10: Suspense المتداخل للبث الدقيق
نمط PPR قوي هو حدود Suspense المتداخلة. هذا يتيح ظهور البيانات السريعة أولاً بينما تستمر البيانات الأبطأ في التحميل:
// src/components/analytics-panel.tsx
import { Suspense } from "react";
export function AnalyticsPanel() {
return (
<div className="space-y-6">
{/* البيانات السريعة تُحمل أولاً */}
<Suspense fallback={<div className="h-20 animate-pulse bg-gray-100 rounded" />}>
<QuickStats /> {/* ~100ms */}
</Suspense>
{/* البيانات المتوسطة تُحمل ثانيًا */}
<Suspense fallback={<div className="h-64 animate-pulse bg-gray-100 rounded" />}>
<DetailedChart /> {/* ~400ms */}
{/* متداخل: البيانات البطيئة تُحمل أخيرًا، لكنها لا تحظر الرسم البياني */}
<Suspense fallback={<div className="h-32 animate-pulse bg-gray-100 rounded" />}>
<DeepAnalysis /> {/* ~1200ms */}
</Suspense>
</Suspense>
</div>
);
}الخطوة 11: معالجة الأخطاء مع PPR
المكونات الديناميكية قد تفشل. استخدم حدود الأخطاء React مع Suspense لمعالجة الأخطاء بأناقة:
// src/app/error-boundary.tsx
"use client";
import { Component, type ReactNode } from "react";
interface Props {
children: ReactNode;
fallback: ReactNode;
}
interface State {
hasError: boolean;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): State {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}استخدمه مع Suspense:
<ErrorBoundary
fallback={
<div className="rounded-xl border border-red-200 bg-red-50 p-6 text-red-700">
فشل تحميل المقاييس. يرجى تحديث الصفحة.
</div>
}
>
<Suspense fallback={<MetricsSkeleton />}>
<LiveMetrics />
</Suspense>
</ErrorBoundary>الخطوة 12: قياس الأداء
شغّل بناء إنتاجي لرؤية تحسين PPR عمليًا:
npm run buildفي مخرجات البناء، سترى رموزًا بجانب كل مسار:
Route (app) Size First Load JS
┌ ◐ / 5.2 kB 92 kB
├ ○ /about 1.1 kB 88 kB
└ ƒ /api/webhook 0 B 0 B
○ (Static) عُرض مسبقًا كمحتوى ثابت
◐ (Partial) عُرض مسبقًا كـ HTML ثابت مع محتوى ديناميكي يُبث من الخادم
ƒ (Dynamic) يُعرض من الخادم عند الطلب
رمز ◐ يشير إلى أن PPR نشط على ذلك المسار.
مقارنة الأداء
شغّل خادم الإنتاج وقِس:
npm run startافتح أدوات المطور في Chrome، اذهب إلى تبويب Performance، وسجّل تحميل صفحة. ستلاحظ:
- FCP: شبه فوري، لأن القالب الثابت يأتي من التخزين المؤقت
- LCP: يعتمد على أكبر عنصر — إذا كان ثابتًا فهو فوري؛ إذا كان ديناميكيًا فيكون عند حل حدود Suspense الخاصة به
- CLS: قريب من الصفر إذا طابقت هياكلك العظمية أبعاد التخطيط النهائي
- TTFB: أقل بكثير من SSR الكامل
أفضل ممارسات PPR
افعل: ضع حدود Suspense بشكل استراتيجي
كل حدود Suspense هي نقطة دخول بث. ضعها حول أقسام واجهة منطقية، وليس عناصر فردية:
// جيد: حدود واحدة لكل قسم منطقي
<Suspense fallback={<MetricsSkeleton />}>
<MetricsGrid /> {/* يحتوي 4 بطاقات مقاييس */}
</Suspense>
// تجنب: حدود صغيرة جدًا (عبء إضافي)
<Suspense fallback={<CardSkeleton />}>
<MetricCard label="الزوار" />
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<MetricCard label="الإيرادات" />
</Suspense>افعل: طابق أبعاد الهيكل العظمي
تأكد أن مكونات الهيكل العظمي لها نفس الأبعاد بالضبط للمحتوى المحمّل. هذا يمنع انزياح التخطيط.
لا تفعل: دمج مكونات ديناميكية بدون Suspense
إذا لم يكن المكون الديناميكي ملفوفًا بـ Suspense، يصبح الصفحة بأكملها ديناميكية — مما يهزم هدف PPR:
// سيء: بدون Suspense — الصفحة كلها تصبح ديناميكية
export default function Page() {
return (
<div>
<StaticHeader />
<DynamicContent /> {/* بدون Suspense! */}
</div>
);
}
// جيد: المحتوى الديناميكي معزول في Suspense
export default function Page() {
return (
<div>
<StaticHeader />
<Suspense fallback={<Loading />}>
<DynamicContent />
</Suspense>
</div>
);
}استكشاف الأخطاء وإصلاحها
"الصفحة ديناميكية بالكامل رغم تفعيل PPR"
هذا يعني عادةً أن دالة ديناميكية تُستدعى خارج حدود Suspense. تحقق من:
cookies()أوheaders()تُستدعى في مكون الصفحة نفسه (وليس في مكون فرعي ملفوف بـ Suspense)searchParamsتُفكك على مستوى الصفحة بدون Suspense حول المكون المستهلكfetch()معcache: "no-store"خارج Suspense
"حالة Suspense الاحتياطية لا تختفي أبدًا"
هذا يعني أن المكون غير المتزامن داخل Suspense يفشل بصمت. لفّه بحدود خطأ لإظهار الخطأ.
"انزياح التخطيط عند تحميل المحتوى الديناميكي"
هيكلك العظمي لا يطابق أبعاد المحتوى النهائي. قِس المكون المعروض وحدّث هيكلك العظمي ليطابقه.
الخطوات التالية
بعد بناء لوحة تحكم مدعومة بـ PPR، إليك طرق لتوسيعها:
- أضف مصادر بيانات حقيقية: استبدل البيانات المحاكاة باستعلامات قاعدة بيانات باستخدام Drizzle أو Prisma
- نفّذ المصادقة: استخدم
cookies()داخل حدود Suspense لعرض بيانات خاصة بالمستخدم - أضف تفاعلية العميل: ادمج PPR مع Client Components للرسوم البيانية باستخدام مكتبات مثل Recharts أو Chart.js
- نفّذ ISR الهجين: استخدم
revalidateعلى أقسام ثابتة معينة للتحديث الدوري - راقب في الإنتاج: استخدم Vercel Analytics أو OpenTelemetry لتتبع أداء PPR الحقيقي
الخلاصة
يزيل Partial Prerendering في Next.js 15 أقدم مقايضة في تطوير الويب: الاختيار بين صفحات ثابتة سريعة ومحتوى ديناميكي مخصص. مع PPR، تحصل على كليهما — قالب ثابت مُخزن مؤقتًا على CDN يُحمّل فوريًا، مع محتوى ديناميكي يتدفق تدريجيًا.
المفاهيم الرئيسية للتذكر:
- كل شيء ثابت افتراضيًا — السلوك الديناميكي اختياري
- حدود Suspense تحدد الفصل بين الثابت والديناميكي — هي بنية العرض الخاصة بك
- كل فجوة ديناميكية تُحل بشكل مستقل — الاستعلامات البطيئة لا تحظر السريعة
- الهياكل العظمية جزء من القالب الثابت — تُعرض مسبقًا وتُخزن مؤقتًا، مما يمنح المستخدمين تغذية بصرية فورية
PPR ليس مجرد تحسين أداء — إنه بنية أفضل جذريًا لبناء تطبيقات الويب. ابدأ استخدامه اليوم في مشاريع Next.js 15 الخاصة بك.
ناقش مشروعك معنا
نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.
دعنا نجد أفضل الحلول لاحتياجاتك.
مقالات ذات صلة

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

بناء روبوت دردشة ذكاء اصطناعي محلي باستخدام Ollama و Next.js: الدليل الشامل
ابنِ روبوت دردشة ذكاء اصطناعي خاص يعمل بالكامل على جهازك المحلي باستخدام Ollama و Next.js. يغطي هذا الدليل العملي التثبيت والبث المباشر واختيار النماذج وبناء واجهة دردشة جاهزة للإنتاج — كل ذلك دون إرسال بياناتك إلى السحابة.

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