0Pricing
Next.js 15 Fullstack (App Router + Server Actions) · 강의

부분 사전 렌더링: 정적 셸과 동적 영역

PPR 모델을 사용하여 사전 렌더링된 정적 셸과 스트리밍되는 동적 영역을 결합하는 방법을 배웁니다.

부분 사전 렌더링: 정적 셸과 동적 영역은(는) CoddyKit의 무료 Next.js 15 Fullstack (App Router + Server Actions) 강의입니다. 이것은 4개 중 3번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 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.

자주 묻는 질문

“부분 사전 렌더링: 정적 셸과 동적 영역” 강의는 무료인가요?

네 — “부분 사전 렌더링: 정적 셸과 동적 영역” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Next.js 15 Fullstack (App Router + Server Actions) 강의 전체를 잠금 해제할 수 있습니다. Next.js 15 Fullstack (App Router + Server Actions) 강의에는 총 4개의 강의가 포함되어 있습니다.

“부분 사전 렌더링: 정적 셸과 동적 영역”에서 뭘 배우나요?

PPR 모델을 사용하여 사전 렌더링된 정적 셸과 스트리밍되는 동적 영역을 결합하는 방법을 배웁니다. 브라우저에서 직접 실행하는 실습 코드로 Next.js 15 Fullstack (App Router + Server Actions)을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

Next.js 15 Fullstack (App Router + Server Actions)을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 Next.js 15 Fullstack (App Router + Server Actions)은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 3번째 강의입니다.

“부분 사전 렌더링: 정적 셸과 동적 영역” 강의는 얼마나 걸리나요?

대부분의 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)(으)로 돌아가기