اگر مدتی با Next.js کار کرده باشید، احتمالاً بارها با این انتخاب سخت روبهرو شدهاید: «این صفحه را استاتیک رندر کنم تا سریع باشد، یا داینامیک تا دادههای لحظهای داشته باشم؟» راستش را بخواهید، خود من چند پروژهای داشتم که فقط به خاطر یک کامپوننت کوچک سبد خرید، کل صفحه به حالت داینامیک میرفت و TTFB افتضاح میشد. Partial Prerendering (به اختصار PPR) دقیقاً پاسخ Next.js 15 به همین دوگانگی است. در این راهنما، PPR را از پایه تا الگوهای پیشرفته با کد واقعی و قابل اجرا بررسی میکنیم.
PPR چیست و چه مشکلی را حل میکند؟
بهصورت سنتی، هر مسیر (route) در Next.js یا کاملاً استاتیک رندر میشد (در زمان build) یا کاملاً داینامیک (در زمان درخواست). یعنی اگر فقط یک بخش کوچک از صفحه نیاز به دادههای کاربر یا real-time داشت، کل صفحه به حالت داینامیک میرفت و سرعت TTFB افت میکرد. این رفتار همه یا هیچ، یکی از بزرگترین دردسرهای معماری App Router در دو سال گذشته بوده.
Partial Prerendering این محدودیت را برمیدارد. ایده اصلی ساده است:
- پوسته استاتیک صفحه (header، sidebar، محتوای ثابت) در زمان build تولید و در CDN کش میشود.
- بخشهای داینامیک (مانند سبد خرید، اعلانها، دادههای کاربر) داخل مرز
<Suspense>قرار میگیرند و هنگام درخواست بهصورت stream از سرور ارسال میشوند.
نتیجه؟ کاربر در همان میلیثانیههای اول، پوسته را از CDN دریافت میکند و بخشهای داینامیک بهصورت تدریجی stream میشوند. به زبان ساده: سرعت یک صفحه استاتیک، با انعطاف یک صفحه داینامیک.
وضعیت PPR در Next.js 15 و 16 (سال ۲۰۲۶)
تا زمان نگارش این مقاله در سال ۲۰۲۶، PPR در Next.js 15 و 16 هنوز بهصورت experimental در دسترس است و نیاز به نسخه canary دارد. تیم Vercel در روادمپ خود اعلام کرده PPR در نسخههای پایدار آینده بهصورت پیشفرض فعال خواهد شد. توصیه شخصی من؟ برای پروژههای production فعلاً بهصورت کنترلشده و فقط روی مسیرهای انتخابی فعالش کنید — نه روی کل اپ.
پیشنیازها
- Node.js نسخه 18.18 یا بالاتر
- Next.js نسخه 15 (canary) یا 16
- آشنایی پایه با App Router و React Server Components
- درک مفهوم
Suspenseدر React 19
فعالسازی PPR در پروژه
خب، بیایید دست به کار شویم. اول از همه نسخه canary را نصب کنید:
npm install next@canary react@rc react-dom@rc
سپس در فایل next.config.ts پرچم experimental را فعال کنید:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental',
},
};
export default nextConfig;
سه مقدار ممکن برای ppr وجود دارد:
true: PPR روی همه مسیرها فعال است.'incremental': فقط مسیرهایی که خودتان opt-in کنید PPR خواهند داشت — گزینهای که برای پروژههای موجود قویاً پیشنهاد میکنم.false: غیرفعال (پیشفرض).
در حالت incremental، باید در هر صفحه یا layout که میخواهید PPR داشته باشد، این خط را اضافه کنید:
// app/dashboard/page.tsx
export const experimental_ppr = true;
اولین مثال PPR: داشبورد کاربر
فرض کنید یک صفحه داشبورد دارید که بخش زیادی از آن (هدر، منو، آمار کلی) ثابت است، اما بخش «اعلانهای اخیر» باید برای هر کاربر داینامیک باشد. سناریوی کلاسیک، نه؟
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { cookies } from 'next/headers';
export const experimental_ppr = true;
async function Notifications() {
const cookieStore = await cookies();
const userId = cookieStore.get('uid')?.value;
const res = await fetch(`https://api.example.com/notifications/${userId}`, {
cache: 'no-store',
});
const data = await res.json();
return (
<ul>
{data.map((n: { id: string; text: string }) => (
<li key={n.id}>{n.text}</li>
))}
</ul>
);
}
export default function DashboardPage() {
return (
<main>
<h1>داشبورد</h1>
<section>
<h2>خلاصه فعالیتها</h2>
<p>این بخش کاملاً استاتیک است و در build تولید میشود.</p>
</section>
<section>
<h2>اعلانها</h2>
<Suspense fallback={<p>در حال بارگذاری اعلانها...</p>}>
<Notifications />
</Suspense>
</section>
</main>
);
}
نکته کلیدی اینجاست: استفاده از cookies() یا headers() داخل کامپوننت Notifications، آن را داینامیک میکند. اما چون داخل مرز <Suspense> قرار گرفته، فقط همین بخش از صفحه دیر آماده میشود؛ بقیه صفحه از CDN تحویل کاربر میشود. ساده، ولی واقعاً تاثیرگذار.
قانون طلایی PPR: مرز Suspense جداکننده استاتیک از داینامیک است
هر چیزی که باعث داینامیک شدن صفحه میشود (مثل cookies()، headers()، searchParams، یا fetch با cache: 'no-store') باید داخل یک مرز <Suspense> قرار بگیرد. در غیر این صورت، Next.js در زمان build خطا میدهد و میگوید نمیتواند pre-render کند.
اگر فراموش کنید Suspense بگذارید، خطایی شبیه این میبینید:
Error: Route /dashboard with `experimental_ppr = true` couldn't be
statically generated because it accessed `cookies()` outside of a
Suspense boundary.
راستش، اولین باری که این خطا را دیدم چند دقیقهای گیر کردم — اما وقتی یکبار منطقش را بفهمید، خیلی شفاف میشود.
الگوی کاربردی ۱: فروشگاه با قیمت داینامیک
صفحه محصول معمولاً اطلاعاتی ثابت دارد (نام، توضیحات، تصاویر) و یک بخش داینامیک (موجودی فعلی، قیمت پویا برای کاربر لاگینکرده):
// app/products/[slug]/page.tsx
import { Suspense } from 'react';
export const experimental_ppr = true;
export async function generateStaticParams() {
const products = await fetch('https://api.shop.com/products').then(r => r.json());
return products.map((p: { slug: string }) => ({ slug: p.slug }));
}
async function ProductInfo({ slug }: { slug: string }) {
const product = await fetch(`https://api.shop.com/p/${slug}`).then(r => r.json());
return (
<div>
<h1>{product.title}</h1>
<p>{product.description}</p>
</div>
);
}
async function LivePricing({ slug }: { slug: string }) {
const pricing = await fetch(`https://api.shop.com/price/${slug}`, {
cache: 'no-store',
}).then(r => r.json());
return (
<div>
<p>قیمت: {pricing.amount.toLocaleString('fa-IR')} تومان</p>
<p>موجودی: {pricing.stock}</p>
</div>
);
}
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return (
<main>
<ProductInfo slug={slug} />
<Suspense fallback={<p>در حال دریافت قیمت...</p>}>
<LivePricing slug={slug} />
</Suspense>
</main>
);
}
در این الگو، ProductInfo در build تولید میشود و در CDN مینشیند. اما LivePricing در هر درخواست از سرور stream میشود. این دقیقاً همان چیزی است که برای صفحات محصول پرترافیک میخواهیم.
الگوی کاربردی ۲: Header با وضعیت لاگین
یک کاربرد بسیار رایج دیگر: نوار بالای سایت معمولاً ثابت است، اما باید وضعیت کاربر (نام، آواتار، سبد خرید) را نشان دهد:
// app/components/Header.tsx
import { Suspense } from 'react';
import { cookies } from 'next/headers';
async function UserMenu() {
const cookieStore = await cookies();
const session = cookieStore.get('session')?.value;
if (!session) return <a href="/login">ورود</a>;
const user = await fetch('https://api.example.com/me', {
headers: { Authorization: `Bearer ${session}` },
cache: 'no-store',
}).then(r => r.json());
return <span>سلام، {user.name}</span>;
}
export function Header() {
return (
<header>
<a href="/">صفحه اصلی</a>
<nav>
<a href="/products">محصولات</a>
<a href="/blog">بلاگ</a>
</nav>
<Suspense fallback={<span>...</span>}>
<UserMenu />
</Suspense>
</header>
);
}
این Header را میتوانید در یک layout.tsx با experimental_ppr = true استفاده کنید تا همه صفحات از مزیت PPR بهره ببرند.
تفاوت PPR با ISR، SSG و SSR
| روش | زمان رندر | کش | کاربرد ایدهآل |
|---|---|---|---|
| SSG | build | بله | صفحات کاملاً ثابت (بلاگ ساده) |
| ISR | build + باز تولید دورهای | بله | صفحاتی که گاهی بهروز میشوند |
| SSR | هر درخواست | خیر | کاملاً وابسته به کاربر/درخواست |
| PPR | build برای پوسته + درخواست برای بخشهای Suspense | پوسته بله، داینامیک خیر | صفحاتی با ترکیب محتوای ثابت و داینامیک |
PPR در کنار Server Actions
یک سؤال رایج: «اگر صفحهام Server Action دارد، آیا میتوانم PPR استفاده کنم؟» پاسخ کوتاه: بله. Server Actions روی POST اجرا میشوند و ربطی به مرحله رندر اولیه ندارند. تنها نکته این است که اگر بعد از یک Action نیاز به نمایش داده تازه دارید، از revalidatePath یا revalidateTag استفاده کنید تا کش بخش استاتیک بازسازی شود.
'use server';
import { revalidateTag } from 'next/cache';
export async function addReview(productSlug: string, formData: FormData) {
await fetch('https://api.shop.com/reviews', {
method: 'POST',
body: JSON.stringify({
slug: productSlug,
text: formData.get('text'),
}),
});
revalidateTag(`product-${productSlug}`);
}
دیباگ و عیبیابی PPR
۱. خطای «could not be statically generated»
یعنی یک API داینامیک خارج از مرز Suspense استفاده کردهاید. کد دارای cookies()، headers()، یا searchParams را در یک کامپوننت جدا داخل <Suspense> منتقل کنید.
۲. fallback خیلی طولانی نمایش داده میشود
این معمولاً به دلیل کند بودن endpoint داینامیک است. از loading.tsx برای fallbackهای زیباتر استفاده کنید و یادتان نرود که fetch را با timeout مناسب تنظیم کنید. میتوانید چند کامپوننت داینامیک را در Suspenseهای جداگانه قرار دهید تا مستقل stream شوند — این یکی از ترفندهای محبوب من است.
۳. در حالت dev همه چیز کار میکند ولی در production خطا میدهد
در محیط dev، Next.js رندر را همیشه بهصورت داینامیک انجام میدهد. حتماً قبل از deploy با next build && next start تست کنید تا خطاهای واقعی PPR را ببینید. این یک گام را هرگز نادیده نگیرید.
۴. بررسی خروجی build
پس از next build، در خروجی به دنبال علامت ◐ (Partial Prerender) در کنار مسیرها بگردید. این نشان میدهد آن مسیر با موفقیت PPR شده است.
بهترین روشها (Best Practices)
- با حالت
incrementalشروع کنید. فعالسازی سراسری برای پروژههای موجود ریسک بالایی دارد. - هر بخش داینامیک را در Suspense جداگانه قرار دهید. این باعث میشود stream موازی انجام شود.
- fallbackها را معنادار طراحی کنید. از skeleton استفاده کنید تا CLS (Cumulative Layout Shift) کم شود.
- از
noStore()بهصورت آگاهانه استفاده کنید. اگر فقط برای یک بخش داینامیک نیاز دارید، آن را در همان کامپوننت داخل Suspense بگذارید نه در کل صفحه. - endpointهای داینامیک را روی edge مستقر کنید. اگر داده داینامیک از یک API روی edge میآید، تأخیر stream بسیار کمتر میشود.
- قبل از deploy حتماً production build بسازید و تست کنید.
محدودیتها و نکات مهم سال ۲۰۲۶
- PPR هنوز بهصورت رسمی stable نشده و API ممکن است تغییر کند.
- برخی هاستهای self-hosted ممکن است streaming را بهخوبی پشتیبانی نکنند؛ Vercel و Cloudflare بهترین نتیجه را میدهند.
- برای صفحات کاملاً داینامیک (مثلاً admin panel)، استفاده از PPR مزیت چندانی ندارد.
- اگر از
output: 'export'استفاده میکنید، PPR کار نمیکند چون نیاز به runtime سرور دارد.
سؤالات متداول (FAQ)
آیا PPR همان ISR است؟
خیر. ISR کل صفحه را بازسازی میکند و یک نسخه استاتیک واحد برای همه کاربران سرو میکند. PPR یک پوسته استاتیک ثابت دارد ولی بخشهای داینامیک آن برای هر کاربر/درخواست متفاوت است و در همان لحظه stream میشود.
آیا PPR در نسخه پایدار Next.js قابل استفاده است؟
تا اوایل ۲۰۲۶، PPR همچنان experimental است و برای استفاده باید نسخه canary را نصب کنید. تیم Next.js اعلام کرده که در نسخههای آینده آن را پایدار خواهد کرد، اما توصیه ما این است که در production فعلاً بهصورت incremental و فقط روی مسیرهای انتخابی استفاده کنید.
آیا PPR روی self-hosted Node.js کار میکند؟
بله، تا زمانی که سرور شما HTTP streaming را پشتیبانی کند. اگر پشت یک reverse proxy مثل Nginx هستید، باید buffering را غیرفعال کنید (proxy_buffering off;) تا stream بهدرستی به کلاینت برسد.
چه زمانی نباید از PPR استفاده کرد؟
اگر صفحه شما کاملاً داینامیک است (هیچ بخش ثابتی ندارد)، یا برعکس کاملاً استاتیک است، PPR ارزش افزودهای ندارد. PPR زمانی میدرخشد که نسبت محتوای ثابت به داینامیک قابل توجه باشد.
تفاوت PPR با Streaming SSR چیست؟
Streaming SSR کل صفحه را در زمان درخواست رندر میکند ولی بهصورت تدریجی به کلاینت میفرستد. PPR یک قدم فراتر میرود: پوسته صفحه از قبل آماده و در CDN است و فقط بخشهای داینامیک در زمان درخواست stream میشوند. نتیجه؟ TTFB بسیار سریعتر.
جمعبندی
Partial Prerendering یک تغییر بنیادی در نحوه فکر کردن ما به صفحات Next.js است. به جای انتخاب بین «استاتیک یا داینامیک»، میتوانید بهترین هر دو دنیا را داشته باشید: پوسته فوقسریع از CDN + بخشهای داینامیک کاربرمحور.
پیشنهاد من این است که از یک مسیر کوچک شروع کنید (مثلاً همان صفحه محصول)، رفتار آن را در production تست کنید، و بعد بهتدریج PPR را به مسیرهای بیشتری گسترش دهید. در سال ۲۰۲۶ که PPR به سمت stable شدن میرود، تیمهایی که زودتر با آن آشنا شوند، در رقابت سرعت و تجربه کاربری چند گام جلوتر خواهند بود. موفق باشید.