A from-scratch implementation of Partial Prerendering (PPR) demonstrating how Next.js prerenders pages with async components using two-phase rendering and React's resume API.
Partial Prerendering combines static and dynamic rendering in a single page:
┌─────────────────────────────────────────────────────────────┐
│ STATIC SHELL (Prerendered at Build Time) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ <Header /> ← Static │ │
│ │ │ │
│ │ <AsyncComponent /> ← Cached (component-level) │ │
│ │ │ │
│ │ ┌───────────────────────────────────────────────────┐ │ │
│ │ │ <Suspense fallback={<Skeleton />}> │ │ │
│ │ │ <UserGreeting /> ← Dynamic (uses cookies) │ │ │
│ │ │ </Suspense> │ │ │
│ │ └───────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ <ProductList /> ← Static │ │
│ │ <Footer /> ← Static │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
This demo implements Next.js's two-phase prerendering approach:
Phase 1: Prospective Render (Cache Filling)
- React prerenders the component tree
- Async components with
cachedComponent()execute their work - Results are stored in the cache
- CacheSignal tracks when all cache reads complete
Phase 2: Final Render (Cache Reading)
- React prerenders again with warm caches
- Cached components return instantly (no async work)
- Static shell is captured with all cached content
Similar to Next.js's 'use cache' directive, this demo caches entire React element trees:
// In Next.js:
async function AsyncComponent() {
'use cache'
await someAsyncWork();
return <div>Cached content</div>;
}
// In this demo:
async function AsyncComponentImpl() {
await someAsyncWork();
return <div>Cached content</div>;
}
export const AsyncComponent = cachedComponent('async-component', AsyncComponentImpl);On cache hit, no component code runs - cached React elements are returned directly.
When a component calls a dynamic API like cookies():
- At build time:
React.unstable_postpone()is called - React captures this as a "dynamic hole" in the static shell
- The Suspense fallback is rendered in place
- At request time:
resumeToPipeableStream()fills the hole with real data
src/
├── cache.js # Component-level caching with CacheSignal
├── async-storage.js # Tracks render mode (prerender vs request)
├── dynamic-apis.js # cookies(), headers() with postpone support
├── build.js # Two-phase prerendering build script
├── server.js # Request-time server with resume()
└── components/
├── App.js # Root component with Suspense boundaries
├── AsyncComponent.js # Cached async component (1-second delay)
├── UserGreeting.js # Dynamic component (uses cookies)
└── ... # Static components
┌──────────────────────────────────────────────────────────────┐
│ PHASE 1: Prospective Render │
│ ─────────────────────────────────────────────────────────── │
│ • Start React prerender │
│ • AsyncComponent executes 1-second delay │
│ • Result cached in memory │
│ • Wait for cacheReady() (all cache reads complete) │
│ • Abort and discard this render │
└──────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────┐
│ PHASE 2: Final Render │
│ ─────────────────────────────────────────────────────────── │
│ • Start React prerender with warm cache │
│ • AsyncComponent returns INSTANTLY from cache │
│ • Dynamic components (cookies) → postpone() │
│ • Capture static shell + postponed state │
│ • Save to dist/ │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ 1. Send static shell immediately │
│ ─────────────────────────────────────────────────────────── │
│ • User sees cached content + loading skeletons │
└──────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────┐
│ 2. resumeToPipeableStream() with postponed state │
│ ─────────────────────────────────────────────────────────── │
│ • React renders ONLY the dynamic parts │
│ • Static shell is NOT re-rendered │
│ • Stream dynamic content to fill holes │
└──────────────────────────────────────────────────────────────┘
This demo requires a custom React build with experimental APIs (unstable_postpone, prerenderToNodeStream). These aren't available in public npm releases.
- Node.js 20+
- Git
- yarn (will be installed if missing)
- yalc (will be installed if missing)
-
Clone this repository:
git clone https://github.com/shakacode/react-ppr-from-scratch.git cd react-ppr-from-scratch -
Clone React and run the setup script:
# Clone React repository git clone https://github.com/facebook/react.git ../react # Run the setup script (builds React and links via yalc) npm run setup-react ../react
The setup script will:
- Checkout React v19.2.3
- Build with experimental channel (
enableHalt=true) - Publish packages via yalc
- Link to this project
-
Install dependencies:
npm install
-
Build and run:
# Build the static shell npm run build # Start the server npm start # Visit http://localhost:3000
- Visit http://localhost:3000 → See "Welcome, Guest!"
- Visit http://localhost:3000/login?name=Alice → Set cookie
- Visit http://localhost:3000 → See "Welcome, Alice!"
- Visit http://localhost:3000/logout → Clear cookie
Watch the terminal to see:
- Cache hits/misses during build
- Resume streaming at request time
src/cache.js- CacheSignal implementation, component-level cachingsrc/build.js- Two-phase prerendering (prospective + final)src/server.js- Request-time resume withresumeToPipeableStream()src/dynamic-apis.js- Howcookies()triggers postponesrc/components/AsyncComponent.js- Cached async component example
For a comprehensive understanding of PPR internals:
-
How PPR Works - Complete deep dive into:
- Two-phase prerendering model
- CacheSignal and async tracking
- Component-level caching
- Dynamic APIs and postpone
- The resume mechanism
- React APIs used
-
Cache Key Generation - How Next.js generates cache keys:
- Compiler transformation with SWC
- SHA-1 hashing of file path + function name
- Argument serialization with Flight protocol
- Why our demo uses manual keys
| Feature | This Demo | Next.js |
|---|---|---|
| Cache Key Generation | Manual string keys | Compiler-generated from file + function name |
| Cache Directive | cachedComponent() wrapper |
'use cache' directive |
| RSC Serialization | Simple JSON | Flight protocol |
| Two-Phase Render | Yes | Yes |
| CacheSignal | Simplified | Full implementation |
| Resume API | resumeToPipeableStream() |
resumeToPipeableStream() |
Next.js uses internal React APIs that aren't publicly exported:
React.unstable_postpone()- Creates "dynamic holes" in static shellprerenderToNodeStream()- Returnspostponedstate objectresumeToPipeableStream()- Continues from postponed state
These require building React with enableHalt=true in the feature flags.
MIT