Skip to content

Partial Prerendering

Partial Prerendering serves a prerendered static shell instantly and streams the dynamic “holes” — each wrapped in Suspense — into that shell at request time, giving one route the speed of static and the freshness of dynamic.

One route, static shell plus dynamic holes

Section titled “One route, static shell plus dynamic holes”

Before PPR you picked one mode per route: fully static (fast, but no per-request data) or fully dynamic (fresh, but nothing prerendered). PPR removes the either/or. The parts of the page that never change are prerendered into a static shell; the parts that need request-time data become dynamic holes, each behind a <Suspense> boundary, streamed in as they resolve.

app/product/[id]/page.tsx
import { Suspense } from 'react';
import { cookies } from 'next/headers';
async function LivePrice() {
const store = await cookies();
const currency = store.get('currency')?.value ?? 'USD';
const price = await fetch('https://api.example.com/price', {
cache: 'no-store',
}).then((r) => r.json());
return <strong>{price.amount} {currency}</strong>;
}
export default function ProductPage() {
return (
<main>
<h1>Wireless Headphones</h1>
<p>This description is prerendered into the static shell.</p>
<Suspense fallback={<span>Loading price…</span>}>
<LivePrice />
</Suspense>
</main>
);
}

The heading and description ship instantly as static HTML; LivePrice is the dynamic hole that streams in per request.

The Suspense fallback becomes part of the prerendered shell

Section titled “The Suspense fallback becomes part of the prerendered shell”

The static shell isn’t just the content outside <Suspense> — it includes the fallback itself. At build time, Next.js renders the fallback UI and bakes it into the static HTML in place of the hole; only when a real request comes in does the actual dynamic content replace it. Building the product page above and reading the real next build legend confirms the route is neither fully static nor fully dynamic:

Route (app)
┌ ○ /
├ ○ /_not-found
└ ◐ /product/[id]
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content

◐ means exactly this: everything outside <Suspense>, plus every fallback, is in the static HTML that ships instantly; everything inside a boundary streams in afterward. This is why the fallback content matters for real — visitors on a slow connection may sit on it for a moment, and search crawlers that can’t wait for streamed content see the fallback as the final state unless they re-render dynamically.

Forcing request-time rendering: connection() and io()

Section titled “Forcing request-time rendering: connection() and io()”

Some code has no Dynamic API to reach for, but still needs to run per-request rather than once at build time — Math.random(), crypto.randomUUID(), or a synchronous database driver. Two functions from next/server / next/cache opt code like that out of the static shell:

app/lib/visitor-count.ts
import { connection } from 'next/server';
// Stands in for any synchronous data source (a sync DB driver, an in-memory
// counter) that would otherwise complete during prerendering with no
// Dynamic API of its own to stop it.
declare function readVisitorCountSync(): number;
export async function getVisitorCount() {
await connection(); // prerendering stops here — the rest only runs at request time
return readVisitorCountSync();
}
app/components/CurrentTime.tsx
import { Suspense } from 'react';
import { io } from 'next/cache';
export default function CurrentTimeSection() {
return (
<Suspense fallback={<p>Loading…</p>}>
<CurrentTime />
</Suspense>
);
}
async function CurrentTime() {
await io(); // suspends during prerendering, so the fallback ships in the shell
return <p>{new Date().toISOString()}</p>;
}

connection() is the older, broader tool: it waits for a real incoming request before continuing, full stop — it replaced unstable_noStore. io() is the Cache Components-specific alternative, and the docs recommend it as the default choice under cacheComponents: it works the same way but the value it excludes can also be cached or prefetched, since io() inside a 'use cache' scope is a no-op — the value is captured into the static shell there instead, no <Suspense> boundary required. Reach for connection() only when rendering must wait for an actual user request; reach for io() everywhere else under Cache Components.

Prefetching the static shell: partialPrefetching

Section titled “Prefetching the static shell: partialPrefetching”

The static shell isn’t just what ships on first load — it’s also what <Link> prefetches ahead of a click. In Next.js 16.3, this is a distinct, separate config flag: partialPrefetching, which requires cacheComponents to already be true.

next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
};
export default nextConfig;

Without it, Next.js prefetches one full route per visible <Link> — a page with many links can trigger many separate prefetch requests. With partialPrefetching: true, Next.js instead prefetches one reusable App Shell per route, shared by every link that points there, and resolves URL-specific content (params, searchParams) only after the actual navigation. A segment can override the app-wide default with its own prefetch export ('auto' the default, 'partial', or 'force-disabled'):

app/product/[id]/page.tsx
export const prefetch = 'partial'; // opt this segment in without flipping the flag app-wide

partialPrefetching shipped in 16.3.0 — it did not exist in earlier 16.x releases, and it is a separate flag from cacheComponents itself, not a value passed to it.

The three models line up along one axis — how much of the route is known at build time:

  • All static — the entire route is prerendered; nothing is per-request. Fastest, but no fresh data.
  • All dynamic — the entire route renders on each request. Always fresh, but nothing is prerendered, so TTFB waits on the slowest data.
  • PPR — the shell (including every Suspense fallback) is prerendered like static, and only the holes are dynamic. Fast first paint and per-request data in the same route.
flowchart LR
  A[Request] --> B[Static shell served instantly]
  B --> E[Shell prerendered at build time, fallback included]
  B --> C[Dynamic hole in Suspense]
  C --> D[Streamed at request time]
A PPR route: static shell with a streamed dynamic hole

A PPR route is a printed brochure with a small live display embedded in it. The brochure — every word of it, including whatever placeholder text sits in the display’s frame — is printed once, in bulk, ahead of time. Only the little screen updates per visitor, and it updates after the brochure is already in their hands. partialPrefetching decides how eagerly that brochure gets handed out before someone even asks for it: one shared brochure design per destination, rather than reprinting one per person who might walk by.

What you getBenefitCost
PPR (cacheComponents: true)Static-speed shell plus per-request holes, one routeEvery dynamic read must sit inside <Suspense> or 'use cache' — no implicit whole-route dynamic mode
connection()Simple, unconditional “wait for a real request”Excludes the value from the static shell entirely — never cacheable, never prefetchable
io()Same exclusion, but a no-op (and cacheable) inside 'use cache'Cache Components-only; not a drop-in replacement outside it
partialPrefetching: trueOne shared App Shell prefetch per route instead of one per linkRequires cacheComponents already on; a 16.3+ feature, not available in earlier 16.x
  • The Suspense fallback is just a loading spinner that disappears quickly. It’s baked into the static shell and served to every visitor and to any client that can’t wait for the stream (some crawlers) — treat its content as seriously as the rest of the static page.
  • connection() and io() are interchangeable. io() is the Cache Components-native choice: it’s a no-op (and thus cacheable) inside a 'use cache' scope, while connection() always excludes its code from the shell, unconditionally.
  • partialPrefetching is just another name for cacheComponents. It’s a separate config key introduced in 16.3.0 that requires cacheComponents: true to already be set — enabling cacheComponents alone does not turn on the new App Shell prefetching behavior.
  • PPR means the whole page is dynamic if any part of it is. The opposite is the entire point: only the code inside a <Suspense> boundary (or outside a 'use cache' scope) is excluded from the static shell — everything else still prerenders.

When a generated snippet touches PPR or Cache Components, check for these before trusting it:

  • Does a component read cookies(), headers(), searchParams, or an uncached fetch without a <Suspense> boundary or 'use cache' scope around it? Under cacheComponents, that’s a build failure, not a runtime surprise.
  • Does the code use Math.random(), Date.now(), or a synchronous DB call directly, assuming it will just work at request time, without connection() or io() to exclude it from the static shell?
  • Is partialPrefetching set without cacheComponents: true already present? The framework throws at config validation — the two flags are separate but dependent.
  • Does the fallback UI for a Suspense boundary contain something that shouldn’t be shown to every visitor by default (a real-looking price, a fake username) — forgetting that the fallback ships in the static shell, not just as a transient loading state?
  • Does the code assume export const experimental_ppr = true or experimental.ppr in next.config still does anything in Next.js 16? Both were removed — PPR is enabled solely through cacheComponents: true.

🐛 Spot the bug

// app/dashboard/page.tsx — next.config.ts has cacheComponents: true
import { cookies } from 'next/headers'
export default async function Dashboard() {
const store = await cookies()
const theme = store.get('theme')?.value ?? 'light'
return (
<main>
<h1>Dashboard</h1>
<p>Active theme: {theme}</p>
</main>
)
}
Show the answer

Under cacheComponents: true, cookies() no longer flips the whole route to dynamic the way it does in the previous model — it now requires an explicit <Suspense> boundary around the code that reads it. This exact page fails next build with: Next.js encountered uncached or runtime data during prerendering. fetch(...), cookies(), headers(), params, searchParams, or connection() accessed outside of <Suspense> prevents the route from being prerendered.... The fix is to move the cookies() read into a child component and wrap that child in <Suspense fallback={...}>, so the heading prerenders into the static shell and only the theme-dependent paragraph becomes a dynamic hole.

What does Partial Prerendering combine in a single route?
How is each dynamic hole in a PPR route delivered?
How do you enable PPR in Next.js 16?
How does PPR differ from an all-dynamic route?
What actually ships in the static shell for a PPR route?
Why is connection() needed here, given the query has no cookies(), headers(), or fetch() call?
async function getVisitorCount() {
  await connection()
  return db.prepare('SELECT value FROM counters WHERE name = ?').get('visitors')
}
What does the partialPrefetching config flag require, and what did it change from?