404 Not Found

404 Not Found


nginx

Partial Prerendering (PPR)

PPR is the most groundbreaking rendering mode in Next.js 16—it allows a single page to combine the lightning-fast responsiveness of a static shell with real-time content in dynamic sections.

1. What You'll Learn


2. A True Story of an Architect

(1) Pain Point: The dashboard is either entirely static (outdated data) or entirely dynamic (slow)

Diana is an architect on the TaskFlow team. The company’s SaaS dashboard faces a dilemma:

What she wants is for the navigation bar/sidebar/layout to be static (generated at build time and cached via CDN), and for user data/notifications to be dynamic (fetched in real time). However, traditional SSG or SSR only allow one or the other—a single page can only have one rendering mode.

(2) Solution to the PPR Problem

Use PPR to split the Dashboard into a static shell (Layout + Navigation) and a dynamic area (Suspense boundary).

TSX
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { NavBar } from '@/components/NavBar'
import { UserGreeting } from '@/components/UserGreeting'
import { NotificationList } from '@/components/NotificationList'
import { Skeleton } from '@/components/Skeleton'

export default function DashboardPage() {
  return (
    <div>
      <NavBar />                           {/* Static Enclosure:Generated during build */}
      <Suspense fallback={<Skeleton />}>  {/* Dynamic Boundary */}
        <UserGreeting />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <NotificationList />
      </Suspense>
    </div>
  )
}

(3) Revenue

Dimension Pure SSR Pure SSG PPR
Time to First Byte (TTFB) 4 seconds 50 ms 50 ms
Data Timeliness ✅ Latest ❌ Snapshot at build time ✅ Real-time for dynamic regions
Server CPU 85% < 5% < 15%
CDN Caching ❌ Not Supported ✅ Full Page ✅ Static Shell
Implementation Complexity Low Low Low (just add Suspense)

3. PPR Concepts and Configuration

The core concept of PPR (Partial Prerendering) is that a page can contain both a pre-rendered static shell and dynamic streaming regions. The static parts are generated during build time, while the dynamic parts are rendered on demand.

(1) Static Shell + Dynamic Boundary

100%
graph TB
    subgraph "PPR Page"
        A[Static Enclosure<br/>Layout + Navigation]
        B[Suspense Boundary 1<br/>User Information - News]
        C[Suspense Boundary 2<br/>List of Notices - News]
        D[Suspense Boundary 3<br/>Real-Time Charts - News]
    end

    A --> B
    A --> C
    A --> D

    style A fill:#d4edda
    style B fill:#cce5ff
    style C fill:#cce5ff
    style D fill:#cce5ff
Component Type Rendering Timing Caching Strategy Typical Components
Static Shell During build CDN cache Layout, NavBar, Footer, Sidebar, Logo
Dynamic Boundary On request Not cached (or short-lived cache) UserGreeting, NotificationList, real-time charts, search

(2) Enable PPR

PPR is disabled by default in Next.js 16 and must be enabled in next.config.ts:

TS
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    ppr: true  // Open PPR
  }
}

export default nextConfig
BASH
# Start the development server after installation
npm run dev
⚠️ Note: PPR requires Next.js 16.2 or later. Once enabled, all pages that do not contain dynamic = 'force-dynamic' or cache: 'no-store' will automatically benefit from PPR optimization by default. You will see the following log during the build: ✓ PPR enabled for /dashboard

▶ Example: PPR vs. Non-PPR Comparison (Difficulty: ⭐)

Output:

TEXT
The React component renders the described UI in the browser.
TSX
// app/ppr-compare/page.tsx — Open PPR Takes effect automatically afterward
import { Suspense } from 'react'

// Static Housing Section(Generated during build)
export default function PprComparePage() {
  return (
    <div>
      <header style={{ background: '#f0f0f0', padding: 16 }}>
        <h1>PPR Demo</h1>
        <nav><a href="/">Home</a> | <a href="/about">About</a></nav>
      </header>

      {/* Dynamic Boundary:Retrieve it anew with every request */}
      <Suspense fallback={<div style={{ padding: 16 }}>Loading user...</div>}>
        <RealtimeUser />
      </Suspense>

      {/* Static Boundary:Rendered during build */}
      <footer style={{ borderTop: '1px solid #ddd', padding: 16 }}>
        <p>Built at: {new Date().toISOString()}</p>
      </footer>
    </div>
  )
}

async function RealtimeUser() {
  const user = await fetch('https://api.example.com/me', { cache: 'no-store' })
    .then(r => r.json())
  return <div style={{ padding: 16 }}>Welcome, {user.name}</div>
}

Output:

TEXT
Renders: Dashboard with static sidebar + dynamic user profile and project stats in Suspense boundaries.

4. Suspense: Boundaries as Dynamic Boundaries

The core principle of PPR: The content of every <Suspense> wrapper is a dynamically rendered boundary. Sections not wrapped by Suspense are static shells that are pre-rendered at build time.

(1) Boundary Rules

100%
graph LR
    A[Page Components] --> B[Static Content<br/>No Suspense]
    A --> C[Suspense boundary]
    C --> D[Dynamic Child Components<br/>Render anew on every request]
    A --> E[Another one Suspense]
    E --> F[Separate Dynamic Zones]
Package Status PPR Behavior Example
✅ Wrapped in <Suspense> Dynamic — Rendered on request, real-time content User information, inventory data
❌ No <Suspense> Static — Rendered during build, cached by CDN Navigation bar, footer, logo

(2) Avoid Unnecessary Suspense

If a component doesn't need real-time data, don't wrap it in Suspense—that way, it will become part of the static shell.

TSX
// app/dashboard/page.tsx
export default function DashboardPage() {
  return (
    <div>
      {/* ✅ Static:The sidebar has always been there,Real-time updates are not required */}
      <Sidebar />

      {/* ✅ News:Need to retrieve in real time */}
      <Suspense fallback={<LoadingSpinner />}>
        <RealtimeData />
      </Suspense>

      {/* ❌ Unnecessary Suspense:This component has no dynamic data. */}
      <Suspense fallback={<LoadingSpinner />}>
        <StaticAboutSection />  {/* No package required */}
      </Suspense>
    </div>
  )
}

▶ Example: Loading Sequence for Multiple Suspense Boundaries (Difficulty: ⭐⭐)

Output:

TEXT
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
Fallback: }>
Visible text: }> | }>
TSX
// app/ppr-timing/page.tsx
import { Suspense } from 'react'

export default function PprTimingPage() {
  return (
    <div>
      <h1>PPR Timing Demo</h1>
      {/* Static Enclosure:Show Now */}
      <p>This appears instantly (static shell)</p>

      {/* Dynamic Boundary 1:2 Display after seconds */}
      <Suspense fallback={<div>⏳ Loading section 1...</div>}>
        <DelayedSection label="Section 1" delay={2000} />
      </Suspense>

      {/* Dynamic Boundary 2:4 Display after seconds,Independent of the Boundary 1 */}
      <Suspense fallback={<div>⏳ Loading section 2...</div>}>
        <DelayedSection label="Section 2" delay={4000} />
      </Suspense>
    </div>
  )
}

async function DelayedSection({ label, delay }: { label: string; delay: number }) {
  await new Promise(resolve => setTimeout(resolve, delay))
  return <div>✅ {label} loaded after {delay}ms</div>
}

Output:

TEXT
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
Fallback: ⏳ Loading section 1...
Visible text: PPR Timing Demo | This appears instantly (static shell) | ⏳ Loading section 1... | }>

5. PPR vs. ISR vs. SSR Performance Comparison

Each of the three rendering modes has its own use cases, and PPR fills the gap for "partially static + partially dynamic" scenarios.

Dimension SSR ISR PPR
Rendering Timing On Every Request Build + On-Demand in the Background Build (Static Shell) + Request (Dynamic Content)
Caching Policy Not Cached by CDN Full-Page CDN Caching Static Shell CDN + Dynamic Areas Not Cached
Real-time ✅ Latest ⚠️ Maximum delay = revalidate ✅ Real-time for dynamic regions
Server Load High Low Low (renders only dynamic parts)
Use Cases Personalized and Authentication Pages Content Sites and Blogs Dashboards and Hybrid Pages
Time to First View Slow (waiting for server-side rendering) Fast (cached) Fast (static shell, instant)

(1) Selection Recommendations

100%
graph TB
    A[This page requires?] --> B{Real-time Data?}
    B -->|The entire page is updated in real time| C[SSR]
    B -->|Real-time in some areas| D[PPR]
    B -->|No real-time data| E{Update Frequency?}
    E -->|Frequent| F[ISR]
    E -->|Virtually unchanged| G[SSG]

    style D fill:#d4edda

▶ Example: PPR Dashboard Component Design (Difficulty: ⭐⭐⭐)

Output:

TEXT
Diagram of rendering strategy: static shell + dynamic Suspense boundaries.
TSX
// app/dashboard-ppr/page.tsx — PPR Practical Design
import { Suspense } from 'react'

// ======== Static Housing Assembly ========
function DashboardHeader() {
  return (
    <header style={{ background: '#1a1a2e', color: 'white', padding: '16px 24px' }}>
      <h1 style={{ margin: 0 }}>TaskFlow Dashboard</h1>
    </header>
  )
}

function Sidebar() {
  return (
    <nav style={{ width: 240, background: '#f5f5f5', padding: 16, minHeight: 'calc(100vh - 64px)' }}>
      <ul style={{ listStyle: 'none', padding: 0 }}>
        <li><a href="/">🏠 Home</a></li>
        <li><a href="/projects">📁 Projects</a></li>
        <li><a href="/tasks">✅ Tasks</a></li>
        <li><a href="/analytics">📊 Analytics</a></li>
        <li><a href="/settings">⚙️ Settings</a></li>
      </ul>
    </nav>
  )
}

// ======== Dynamic Boundary Component ========
async function UserProfile() {
  const user = await fetch('https://api.example.com/me', { cache: 'no-store' }).then(r => r.json())
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
      <img src={user.avatar} alt="" style={{ borderRadius: '50%', width: 40, height: 40 }} />
      <div>
        <strong>{user.name}</strong>
        <p style={{ margin: 0, fontSize: 12, color: '#666' }}>{user.role}</p>
      </div>
    </div>
  )
}

async function ProjectStats() {
  const stats = await fetch('https://api.example.com/stats', { next: { revalidate: 60 } }).then(r => r.json())
  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16 }}>
      <StatCard label="Active Projects" value={stats.activeProjects} color="#4caf50" />
      <StatCard label="Pending Tasks" value={stats.pendingTasks} color="#ff9800" />
      <StatCard label="Completed" value={stats.completed} color="#2196f3" />
    </div>
  )
}

function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
  return (
    <div style={{ border: `1px solid ${color}`, borderRadius: 8, padding: 16, textAlign: 'center' }}>
      <p style={{ fontSize: 28, fontWeight: 'bold', color, margin: 0 }}>{value}</p>
      <p style={{ margin: 0, color: '#666', fontSize: 14 }}>{label}</p>
    </div>
  )
}

export default function DashboardPprPage() {
  return (
    <div style={{ display: 'flex' }}>
      <Sidebar />
      <main style={{ flex: 1, padding: 24 }}>
        <DashboardHeader />
        <Suspense fallback={<div>Loading profile...</div>}>
          <UserProfile />
        </Suspense>
        <div style={{ height: 24 }} />
        <Suspense fallback={<div>Loading stats...</div>}>
          <ProjectStats />
        </Suspense>
      </main>
    </div>
  )
}

Output:

TEXT
Fetches data server-side and renders the result in the page.
Visible content: TaskFlow Dashboard

6. Complete Example: PPR E-commerce Dashboard

TSX
// app/ppr-ecommerce/page.tsx — PPR E-commerce Dashboard
import { Suspense } from 'react'
import { cookies } from 'next/headers'

// ======== Static Enclosure ========
function StoreHeader() {
  return (
    <header style={{ background: '#2c3e50', color: 'white', padding: '12px 24px', display: 'flex', justifyContent: 'space-between' }}>
      <strong>Store Dashboard</strong>
      <span>Built: {new Date().toISOString().split('T')[0]}</span>
    </header>
  )
}

function Navigation() {
  return (
    <nav style={{ background: '#34495e', padding: '8px 24px', display: 'flex', gap: 24, color: 'white' }}>
      <a href="/ppr-ecommerce" style={{ color: 'white' }}>Overview</a>
      <a href="/ppr-ecommerce/orders" style={{ color: 'white' }}>Orders</a>
      <a href="/ppr-ecommerce/products" style={{ color: 'white' }}>Products</a>
    </nav>
  )
}

// ======== Dynamic Components ========
async function LiveOrderFeed() {
  const orders = await fetch('https://api.example.com/orders/recent', {
    cache: 'no-store'
  }).then(r => r.json())

  return (
    <div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16 }}>
      <h2>Live Orders ({orders.length})</h2>
      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Total</th></tr></thead>
        <tbody>
          {orders.map((o: any) => (
            <tr key={o.id}>
              <td>#{o.id}</td>
              <td>{o.customer}</td>
              <td><StatusBadge status={o.status} /></td>
              <td>${o.total}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

function StatusBadge({ status }: { status: string }) {
  const colors: Record<string, string> = {
    pending: '#ff9800', shipped: '#2196f3', delivered: '#4caf50', cancelled: '#f44336'
  }
  return <span style={{ background: colors[status] ?? '#ccc', color: 'white', padding: '2px 8px', borderRadius: 12, fontSize: 12 }}>{status}</span>
}

async function RevenueWidget() {
  const revenue = await fetch('https://api.example.com/revenue/today', {
    next: { revalidate: 300 }
  }).then(r => r.json())

  return (
    <div style={{ background: 'linear-gradient(135deg, #667eea, #764ba2)', color: 'white', borderRadius: 8, padding: 24 }}>
      <h2 style={{ margin: 0, fontSize: 14, opacity: 0.8 }}>Today's Revenue</h2>
      <p style={{ fontSize: 36, fontWeight: 'bold', margin: '8px 0' }}>${revenue.total}</p>
      <p style={{ margin: 0, fontSize: 12, opacity: 0.8 }}>↑ {revenue.growth}% vs yesterday</p>
    </div>
  )
}

async function TopProducts() {
  const products = await fetch('https://api.example.com/products/top', {
    next: { tags: ['top-products'] }
  }).then(r => r.json())

  return (
    <div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16 }}>
      <h2>Top Products</h2>
      <ol>{products.slice(0, 5).map((p: any) => (
        <li key={p.id}>{p.name} — {p.sold} sold</li>
      ))}</ol>
    </div>
  )
}

// ======== Page(PPR Mixed) ========
export default function PprEcommercePage() {
  return (
    <div>
      <StoreHeader />
      <Navigation />
      <main style={{ padding: 24, display: 'grid', gap: 24, gridTemplateColumns: '2fr 1fr' }}>
        <div>
          <Suspense fallback={<div>Loading orders...</div>}>
            <LiveOrderFeed />
          </Suspense>
        </div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
          <Suspense fallback={<div>Loading revenue...</div>}>
            <RevenueWidget />
          </Suspense>
          <Suspense fallback={<div>Loading products...</div>}>
            <TopProducts />
          </Suspense>
        </div>
      </main>
    </div>
  )
}

▶ Example: Build Artifact Verification (Difficulty: ⭐)

BASH
# View the static shell output after building
npm run build

# In .next/server/app/ppr-ecommerce, scroll down to view
ls .next/server/app/ppr-ecommerce/

Output:

TEXT
page.html          ← Static Enclosure HTML(Navigation Bar、Layout)
page.rsc           ← RSC Payload(Static Section)
page_stream.html   ← Flow-Through Partial Injection Point

▶ Example: PPR Static Shell + Dynamic Data Validation (Difficulty: ⭐⭐)

Output:

TEXT
The page renders as described above, with the UI updating based on the described behavior.
TSX
// app/ppr-verify/page.tsx
import { Suspense } from 'react'

function StaticHeader() {
  return (
    <header style={{ borderBottom: '2px solid #333', padding: 16, marginBottom: 16 }}>
      <h1>PPR Verification Page</h1>
      <p>Build timestamp: {new Date().toISOString()}</p>
      <nav><a href="/">Home</a> | <a href="/ppr-verify">Refresh</a></nav>
    </header>
  )
}

async function DynamicContent() {
  const res = await fetch('http://worldtimeapi.org/api/timezone/Etc/UTC', {
    cache: 'no-store'
  }).then(r => r.json())

  return (
    <div style={{ background: '#e3f2fd', padding: 16, borderRadius: 8 }}>
      <h2>Live Server Time</h2>
      <p style={{ fontSize: 24 }}>{res.datetime}</p>
    </div>
  )
}

export default function PprVerifyPage() {
  return (
    <div>
      <StaticHeader />
      <Suspense fallback={<div style={{ padding: 16 }}>⏳ Loading live time...</div>}>
        <DynamicContent />
      </Suspense>
      <footer style={{ marginTop: 32, color: '#666' }}>
        <p>Header is static shell (cached). Dynamic content updates per request.</p>
      </footer>
    </div>
  )
}

Output:

TEXT
Fetches data server-side and renders the result in the page.
Visible content: PPR Verification Page

❓ FAQ

Q What is the difference between PPR and Streaming SSR?
A With Streaming SSR, rendering occurs only when a request is made, and each Suspense boundary is "dynamic." PPR’s static shell is generated at build time—users see HTML cached on the CDN, not server-side real-time rendering. PPR = static shell + Streaming dynamic regions.
Q Will all pages switch to PPR after enabling it?
A Yes, by default, all routes without dynamic = 'force-dynamic' will use PPR. However, only pages containing the <Suspense> boundary will have the "static shell + dynamic region" effect. Pages without Suspense will remain purely static (SSG behavior).
Q Can PPR dynamic regions be nested?
A Yes. Suspense boundaries can be nested—the inner Suspense depends on the outer one. However, it is recommended to maintain a flat structure (2–5 Suspense boundaries per page), as excessive nesting can lead to a complex loading order.
Q How is data updated in a PPR static shell?
A Data in a static shell is locked at build time. If you render a username in the layout, it is a snapshot from build time. Solution: Place user-specific data inside a dynamic Suspense boundary, and reserve the static shell for content that never changes (such as logos, navigation links, and layout structure).
Q Can PPR coexist with other rendering modes?
A Yes. Within the same application, you can have PPR pages (Dashboard), SSG pages (Marketing), ISR pages (Blog), and SSR pages (Admin). Rendering modes are configured at the route level—each route segment is configured independently.
Q How does PPR behave in development mode?
A PPR still works in development mode, but since each request is rebuilt from scratch, the benefits of the static shell (CDN caching) aren’t noticeable locally. You’ll only truly experience the performance boost from PPR after a production build. You can run npm run build && npm run start to test production mode.

📖 Summary


📝 Exercises

  1. Basic Exercise (⭐): Enable PPR in next.config.ts, create a app/ppr-basic/page.tsx that contains a static <header> section and a dynamic <Suspense> section (call the API to retrieve the current time), and verify that the build output includes static HTML.

  2. Advanced Problem (⭐⭐): Create a app/ppr-dashboard/page.tsx that contains at least 3 Suspense boundaries (user information, notification list, real-time statistics), each with its own fallback. Ensure that the navigation bar and sidebar are static wrappers. After building, verify that page.html includes the navigation bar but does not contain any dynamic content.

  3. Challenge (⭐⭐⭐): Build a PPR multi-page dashboard—app/ppr-portal/—with three subpages: Overview, Orders, and Analytics. These pages share a static layout (top navigation + sidebar), and each page contains 2–4 dynamic Suspense boundaries. Add Server Actions to allow users to submit data within the dynamic areas, triggering revalidateTag() to refresh the corresponding dynamic areas.

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏