0Pricing
Next.js 15 Fullstack (App Router + Server Actions) · 课时

部分预渲染:静态外壳与动态空洞

使用 PPR 模型,将预渲染的静态外壳与流式传输的动态区域结合起来。

部分预渲染:静态外壳与动态空洞 是 CoddyKit 上的免费 Next.js 15 Fullstack (App Router + Server Actions) 课时。 这是第 3 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Next.js 15 Fullstack (App Router + Server Actions) 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Next.js 15 Fullstack (App Router + Server Actions) 课程共包含 4 节课。

本课时的部分内容尚未翻译,以英文显示。

What Is Partial Prerendering?

Partial Prerendering (PPR) is a rendering model introduced in Next.js 14+ and refined in Next.js 15 that lets a single route serve both static and dynamic content simultaneously.

With traditional rendering you had to pick one:

  • Static (SSG/SSR with full cache) — fast, but stale for personalized data
  • Dynamic (SSR on every request) — fresh, but slower Time-To-First-Byte

PPR breaks that trade-off. At build time Next.js pre-renders a static shell (layout, chrome, above-the-fold content) and punches out holes where dynamic data will stream in at request time — all from one route file.

Enabling PPR in Next.js 15

PPR is an opt-in feature. Enable it in next.config.ts with the experimental.ppr flag.

In Next.js 15 you can also enable it incrementally — per route — using the experimental_ppr route-segment config export instead of turning it on globally.

Once enabled, the framework analyses your route tree at build time, separating the static shell from any subtrees that contain dynamic APIs (cookies(), headers(), searchParams, noStore(), etc.).

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

const nextConfig: NextConfig = {
  experimental: {
    ppr: true, // enable globally
    // OR use 'incremental' to opt in per-route
    // ppr: 'incremental',
  },
};

export default nextConfig;

Opting a Route Into PPR Incrementally

When you set ppr: 'incremental' in next.config.ts, individual route segments must explicitly opt in by exporting the experimental_ppr constant set to true.

This is ideal for migrating large apps gradually — only the pages you annotate get the PPR treatment, while the rest behave as before.

The export must live directly in the page or layout file of the segment you want to prerender partially.

// app/dashboard/page.tsx
// Opt this route into Partial Prerendering
export const experimental_ppr = true;

export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      {/* static shell renders at build time */}
      {/* dynamic holes are defined below with Suspense */}
    </main>
  );
}

The Static Shell

The static shell is everything in your route that does not depend on dynamic APIs. Next.js pre-renders and caches this at build time (CDN-ready) so it is delivered instantly — before any database call fires.

Typical static shell content includes:

  • Page layout and navigation chrome
  • Hero sections with hardcoded or statically-fetched copy
  • Skeleton / placeholder UI for the dynamic holes
  • Server Components that only read from the filesystem or static fetch with full cache

The key insight: the static shell is served as a prerendered HTML stream while the dynamic holes continue loading in parallel.

// app/store/page.tsx
import { Suspense } from 'react';
import ProductListSkeleton from '@/components/ProductListSkeleton';
import DynamicCart from '@/components/DynamicCart';
import StaticHero from '@/components/StaticHero'; // no dynamic APIs

export const experimental_ppr = true;

export default function StorePage() {
  return (
    <div>
      {/* Static shell — prerendered at build time */}
      <StaticHero />
      <nav>Store Navigation</nav>

      {/* Dynamic hole — streams in at request time */}
      <Suspense fallback={<ProductListSkeleton />}>
        <DynamicCart />
      </Suspense>
    </div>
  );
}

Defining Dynamic Holes with Suspense

A dynamic hole is any subtree wrapped in a <Suspense> boundary that contains components using dynamic Next.js APIs.

Next.js uses the Suspense boundary as the boundary between static and dynamic. Everything inside the boundary is excluded from the prerendered shell and instead streamed as a separate chunk once data is ready.

Important rules:

  • The dynamic Server Component must be the async component doing the data fetch — not just a wrapper
  • You must provide a fallback to the Suspense boundary; this fallback is baked into the static shell so users see it immediately
  • Multiple independent Suspense boundaries create multiple independent holes that stream in parallel
// components/DynamicCart.tsx
import { cookies } from 'next/headers'; // marks this subtree as dynamic

async function DynamicCart() {
  const cookieStore = await cookies();
  const sessionId = cookieStore.get('session_id')?.value;

  const cart = sessionId
    ? await fetchCart(sessionId) // DB call at request time
    : null;

  return (
    <aside>
      <h2>Your Cart</h2>
      {cart ? (
        <ul>
          {cart.items.map((item) => (
            <li key={item.id}>{item.name} — ${item.price}</li>
          ))}
        </ul>
      ) : (
        <p>Your cart is empty.</p>
      )}
    </aside>
  );
}

export default DynamicCart;

async function fetchCart(sessionId: string) {
  // simulated DB call
  return { items: [{ id: 1, name: 'Widget', price: 9.99 }] };
}

Multiple Independent Dynamic Holes

One of PPR's biggest advantages is that you can have multiple independent dynamic holes in a single route. Each Suspense boundary streams its content independently, so a slow database call in one region does not block another.

Think of a dashboard with:

  • A user greeting (reads cookies())
  • A live stats widget (reads from a slow analytics DB)
  • A recent activity feed (reads from a different table)

All three stream in parallel after the static shell is delivered, giving users a progressively enhanced experience rather than a single long wait.

// app/dashboard/page.tsx
import { Suspense } from 'react';
import UserGreeting from '@/components/UserGreeting';
import LiveStats from '@/components/LiveStats';
import ActivityFeed from '@/components/ActivityFeed';
import {
  GreetingSkeleton,
  StatsSkeleton,
  FeedSkeleton,
} from '@/components/Skeletons';

export const experimental_ppr = true;

export default function DashboardPage() {
  return (
    <main className="dashboard-grid">
      <h1>Welcome to your Dashboard</h1> {/* static */}

      {/* Three independent holes — stream in parallel */}
      <Suspense fallback={<GreetingSkeleton />}>
        <UserGreeting />
      </Suspense>

      <Suspense fallback={<StatsSkeleton />}>
        <LiveStats />
      </Suspense>

      <Suspense fallback={<FeedSkeleton />}>
        <ActivityFeed />
      </Suspense>
    </main>
  );
}

What Makes a Component Dynamic?

Next.js automatically detects dynamic usage by scanning for specific APIs. A Server Component is considered dynamic (excluded from the static shell) when it uses:

  • cookies() or headers() from next/headers
  • searchParams prop on a page
  • unstable_noStore() or fetch with cache: 'no-store'
  • Any API that opts out of the Data Cache at request time

If a dynamic API is used outside a Suspense boundary, the entire route falls back to full dynamic rendering — PPR is disabled for that route. Always wrap dynamic components in Suspense to preserve the static shell.

// components/UserGreeting.tsx
import { cookies, headers } from 'next/headers';

export async function UserGreeting() {
  // Both of these mark this component as dynamic
  const cookieStore = await cookies();
  const headersList = await headers();

  const userId = cookieStore.get('user_id')?.value;
  const userAgent = headersList.get('user-agent') ?? 'Unknown';

  const user = userId ? await fetchUser(userId) : null;

  return (
    <div>
      <p>Hello, {user?.name ?? 'Guest'}!</p>
      <small>Browsing via: {userAgent.split('/')[0]}</small>
    </div>
  );
}

async function fetchUser(id: string) {
  // imagine a real DB query here
  return { name: 'Mehmet' };
}

Skeleton Fallbacks Are Part of the Static Shell

The fallback prop of each Suspense boundary is included in the prerendered static shell. This means users receive the skeleton UI with zero delay — the same latency as a fully static page.

Design your skeletons to closely match the final content's dimensions and layout. This minimises Cumulative Layout Shift (CLS) when the dynamic content streams in and replaces the skeleton.

A good skeleton strategy:

  • Match width and height of the real content as closely as possible
  • Use CSS animations (pulse/shimmer) to communicate loading state
  • Avoid placeholders that look like real data — users may read them as content
// components/Skeletons.tsx
export function StatsSkeleton() {
  return (
    <div className="animate-pulse space-y-2 p-4 rounded-lg bg-gray-100">
      <div className="h-4 bg-gray-300 rounded w-1/3" />
      <div className="h-8 bg-gray-300 rounded w-1/2" />
      <div className="h-4 bg-gray-300 rounded w-2/3" />
    </div>
  );
}

export function FeedSkeleton() {
  return (
    <ul className="animate-pulse space-y-3">
      {Array.from({ length: 5 }).map((_, i) => (
        <li key={i} className="flex gap-3 items-center">
          <div className="h-8 w-8 rounded-full bg-gray-300" />
          <div className="flex-1 h-4 bg-gray-300 rounded" />
        </li>
      ))}
    </ul>
  );
}

export function GreetingSkeleton() {
  return <div className="animate-pulse h-6 bg-gray-300 rounded w-40" />;
}

PPR vs Traditional Streaming SSR

It is easy to confuse PPR with regular streaming SSR (using Suspense without PPR). Here is the key difference:

  • Streaming SSR (no PPR) — The entire response is rendered on the server at request time. The static parts render fast, dynamic parts stream later. But the initial HTML is never cached at the CDN edge — every request hits your server.
  • PPR — The static shell is pre-rendered at build time and served from the CDN edge instantly. Only the dynamic holes trigger server execution at request time. You get CDN-speed static delivery and fresh dynamic data.

PPR is effectively: CDN static delivery + request-time streaming combined in one route.

Reading searchParams Without Breaking PPR

In Next.js 15, searchParams on a Page component is a dynamic value — reading it opts the entire page out of static rendering. With PPR, you must not read searchParams directly in the page component (outside a Suspense boundary).

Instead, pass searchParams as a prop into a component that is wrapped in Suspense. This keeps the outer page shell static while allowing the inner component to read request-time query params.

// app/search/page.tsx
import { Suspense } from 'react';
import SearchResults from '@/components/SearchResults';
import SearchResultsSkeleton from '@/components/SearchResultsSkeleton';

export const experimental_ppr = true;

type SearchPageProps = {
  searchParams: Promise<{ q?: string; page?: string }>;
};

export default function SearchPage({ searchParams }: SearchPageProps) {
  // Do NOT await searchParams here — that would make the shell dynamic.
  // Pass the Promise into the dynamic hole instead.
  return (
    <div>
      <h1>Search</h1> {/* static shell */}

      <Suspense fallback={<SearchResultsSkeleton />}>
        {/* SearchResults awaits searchParams inside the Suspense boundary */}
        <SearchResults searchParamsPromise={searchParams} />
      </Suspense>
    </div>
  );
}

Verifying PPR Behaviour in Development

During next dev, PPR is simulated but pages are not truly pre-built — every request re-renders. To observe actual PPR behaviour you need to run a production build:

  • next build — generates the static shell at build time
  • next start — serves the production output

After a build, check the output table printed to the terminal. Routes with PPR are marked with a ◐ symbol (half-filled circle) indicating a partial prerender — distinct from fully static (○) and fully dynamic (λ) routes.

You can also inspect the .next/server/app directory for the pre-rendered HTML file and the separate RSC payload for the dynamic holes.

Knowledge Check: PPR Boundary Rules

A developer enables PPR on a route and wraps their dynamic component in a Suspense boundary. Which of the following statements correctly describes what happens if the developer calls cookies() directly inside the Page component (outside any Suspense boundary)?

Recap: Partial Prerendering in Practice

In this lesson you explored Partial Prerendering (PPR) — the Next.js 15 model that merges CDN-speed static delivery with request-time dynamic streaming.

Key takeaways:

  • Enable PPR globally via experimental.ppr: true in next.config.ts, or per-route with export const experimental_ppr = true
  • The static shell (everything outside Suspense boundaries) is pre-rendered at build time and served instantly from the CDN
  • Each Suspense boundary defines a dynamic hole — its fallback is baked into the shell, its content streams in at request time
  • Dynamic APIs (cookies(), headers(), searchParams, no-store fetches) must live inside Suspense boundaries to preserve the shell
  • Multiple independent Suspense boundaries stream in parallel, maximising perceived performance
  • Build with next build and look for the ◐ symbol to confirm PPR is active on a route

PPR is the bridge between the all-or-nothing static vs dynamic choice — use it to deliver fast shells and fresh data from the same route file.

常见问题解答

「部分预渲染:静态外壳与动态空洞」课时是免费的吗?

是的 — 「部分预渲染:静态外壳与动态空洞」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Next.js 15 Fullstack (App Router + Server Actions) 课程的其余内容,请升级到 CoddyKit PRO。 Next.js 15 Fullstack (App Router + Server Actions) 课程共包含 4 节课。

「部分预渲染:静态外壳与动态空洞」这节课中我会学到什么?

使用 PPR 模型,将预渲染的静态外壳与流式传输的动态区域结合起来。 你通过在浏览器中直接运行的动手代码来练习 Next.js 15 Fullstack (App Router + Server Actions),全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 Next.js 15 Fullstack (App Router + Server Actions) 需要有经验吗?

无需任何先前经验。CoddyKit 上的 Next.js 15 Fullstack (App Router + Server Actions) 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 3 节课,共 4 节。

「部分预渲染:静态外壳与动态空洞」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 Next.js 15 Fullstack (App Router + Server Actions) 课中编写并运行代码吗?

能。每节 Next.js 15 Fullstack (App Router + Server Actions) 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. Suspense 边界与组件级流式传输
  2. 打造有意义的 loading.tsx 与骨架屏
  3. 部分预渲染:静态外壳与动态空洞
  4. 流式传输陷阱:布局偏移与瀑布流
← 返回 Next.js 15 Fullstack (App Router + Server Actions)