Inside the App Router Rendering Engine: RSC, Streaming, and PPR
Prerequisites
- ›Articles 1-2: Architecture overview and server boot/request lifecycle
- ›React Server Components concepts — server/client boundaries, 'use client' and 'use server' directives
- ›Node.js Streams — ReadableStream, TransformStream, piping
- ›Node.js AsyncLocalStorage for request-scoped context propagation
Inside the App Router Rendering Engine: RSC, Streaming, and PPR
At the heart of the Next.js App Router lies app-render.tsx — a ~7,350-line file that orchestrates everything from React Server Components rendering to HTML streaming to Partial Prerendering. This is where the framework's most sophisticated features converge: Flight payloads for client navigation, Fizz streams for initial HTML delivery, and the postpone mechanism that enables static shells with dynamic holes.
This article traces the rendering pipeline from the route module entry point through the streaming output, revealing the architectural patterns that make it all work.
Route Module System: Wrapping User Code
As we saw in Article 2, requests are dispatched to route modules after route matching. For App Router pages, the entry point is AppPageRouteModule:
classDiagram
class RouteModule {
<<abstract>>
+definition: RouteDefinition
+userland: UserlandModule
+setup(): Promise
+handle(req, res, context)
#manifests: Manifests
#incrementalCache: IncrementalCache
}
class AppPageRouteModule {
+render(req, res, context)
-vendoredReactRSC
-vendoredReactSSR
+loaderTree: LoaderTree
}
RouteModule <|-- AppPageRouteModule
The route module serves as the bridge between the server's generic request pipeline and the rendering engine. A critical detail is how React itself is loaded. At module.ts#L40-L53, the module loads two separate React instances — vendoredReactRSC for the server component render and vendoredReactSSR for the HTML render. These are vendored copies that Next.js controls, ensuring version consistency and preventing conflicts with user-installed React.
The AppPageModule type at line 59 tells us what the bundled app page module looks like — it has a loaderTree, which is the data structure that describes the nested layout/page hierarchy discovered from the app/ directory.
AsyncLocalStorage as Dependency Injection
Before diving into rendering, we need to understand how Next.js passes context through deeply nested component trees without prop drilling. The answer is AsyncLocalStorage — Node.js's mechanism for propagating context across async boundaries.
Two stores are central:
WorkStore — per-render context that persists across the entire rendering of a page:
export interface WorkStore {
readonly isStaticGeneration: boolean
readonly page: string
readonly route: string
readonly incrementalCache?: IncrementalCache
readonly cacheLifeProfiles?: { [profile: string]: CacheLife }
forceDynamic?: boolean
forceStatic?: boolean
}
RequestStore — per-request context with the actual HTTP data:
export interface RequestStore extends CommonWorkUnitStore {
readonly type: 'request'
readonly url: { readonly pathname: string; readonly search: string }
readonly headers: ReadonlyHeaders
cookies: ReadonlyRequestCookies
readonly mutableCookies: ResponseCookies
}
flowchart TD
Render["renderToHTMLOrFlight()"] --> WS["WorkStore\n(AsyncLocalStorage)"]
Render --> RS["RequestStore\n(AsyncLocalStorage)"]
WS --> HeadersAPI["headers()"]
WS --> CookiesAPI["cookies()"]
WS --> CacheDecisions["Cache/Static decisions"]
RS --> HeadersAPI
RS --> CookiesAPI
RS --> DraftMode["draftMode()"]
subgraph "User Code (Server Components)"
HeadersAPI
CookiesAPI
DraftMode
end
When you call headers() in a Server Component, the implementation reads from the RequestStore via workUnitAsyncStorage.getStore(). This is how Next.js APIs work without receiving explicit arguments — they reach into the ambient async context. It's dependency injection via the runtime, and it's the reason these APIs can only be called during a render (they throw if no store is found).
Tip: The
.external.tssuffix on these files is significant. It indicates that these modules are shared across bundler boundaries — they use import attributes likewith { 'turbopack-transition': 'next-shared' }to ensure the same instance is used regardless of how modules are resolved.
renderToHTMLOrFlight: The Core Entry Point
The public entry point is renderToHTMLOrFlight at line 2691. It parses request headers to determine the rendering mode, then delegates to the ~500-line renderToHTMLOrFlightImpl starting at line 2205.
The critical branching decision is: HTML or Flight?
flowchart TD
Entry["renderToHTMLOrFlight()"] --> Parse["Parse Request Headers"]
Parse --> RSCCheck{"RSC_HEADER\npresent?"}
RSCCheck -->|Yes| Prefetch{"Prefetch\nrequest?"}
RSCCheck -->|No| HTML["Full HTML Render\n(Fizz streaming)"]
Prefetch -->|Yes| PrefetchFlight["Prefetch Flight Payload\n(static shell only)"]
Prefetch -->|No| NavigationFlight["Navigation Flight Payload\n(full RSC data)"]
HTML --> Postponed{"Postponed\nstate?"}
Postponed -->|Yes| Resume["Resume PPR\n(fill dynamic holes)"]
Postponed -->|No| FullRender["Full Server Render"]
FullRender --> StaticCheck{"Static\ngeneration?"}
StaticCheck -->|Yes| StaticPrerender["Prerender\n(cache result)"]
StaticCheck -->|No| DynamicRender["Dynamic Render\n(stream to client)"]
When the browser makes an initial page load, there's no RSC_HEADER — the server produces full HTML using React's Fizz streaming renderer. When the client-side router navigates (as we'll see in Article 4), it sends a request with the RSC_HEADER, and the server returns a Flight payload instead — a binary format that describes the React tree without HTML.
The renderToHTMLOrFlightImpl function sets up the WorkStore and RequestStore via createWorkStore() and createRequestStoreForRender(), then builds the React element tree and renders it.
Building the Component Tree
The create-component-tree.tsx module transforms the loader tree (generated by next-app-loader from the app/ directory structure) into a React element tree:
graph TD
LoaderTree["Loader Tree\n(from next-app-loader)"] --> RootLayout["Root Layout\n(app/layout.tsx)"]
RootLayout --> ErrorBoundary1["ErrorBoundary"]
ErrorBoundary1 --> Template1["Template"]
Template1 --> Suspense1["Suspense\n(loading.tsx)"]
Suspense1 --> NestedLayout["Nested Layout\n(app/dashboard/layout.tsx)"]
NestedLayout --> ErrorBoundary2["ErrorBoundary"]
ErrorBoundary2 --> Template2["Template"]
Template2 --> Suspense2["Suspense"]
Suspense2 --> Page["Page\n(app/dashboard/page.tsx)"]
For each segment in the loader tree, create-component-tree wraps the component with error boundaries (from error.tsx), loading states (from loading.tsx), and templates (from template.tsx). This wrapping is what enables the App Router's granular error handling and loading UI — each segment can independently show a loading spinner or catch errors without affecting sibling segments.
The function also handles 'use client' boundaries. When it encounters a client reference, it doesn't render the component on the server — instead, it emits a client reference marker that the Flight protocol uses to tell the browser "load this module and render it client-side."
Streaming Pipeline: Fizz and Flight
The rendering output is always a stream, never a complete string. Next.js uses two React renderers:
- Fizz (
react-dom/server) — Renders React elements to an HTML stream with Suspense boundary support - Flight (
react-server-dom-webpack/server) — Serializes the React tree into a binary payload for client-side reconstruction
The stream operations are abstracted in stream-ops.ts, which re-exports from stream-ops.web.ts. Key operations include:
renderToFizzStream— Renders the component tree to HTMLrenderToFlightStream— Serializes RSC data for client navigationcreateInlinedDataStream— Injects Flight data as<script>tags into the HTML streamcontinueFizzStream— Handles Suspense boundary resolution and streaming
sequenceDiagram
participant App as React Tree
participant Flight as Flight Renderer
participant Fizz as Fizz Renderer
participant Transform as Stream Transform
participant Client as Browser
App->>Flight: Serialize RSC tree
Flight-->>Fizz: React elements (with client refs)
Fizz-->>Transform: HTML chunks
Flight-->>Transform: Flight data chunks
Transform->>Transform: Interleave HTML + <script> tags
Transform->>Client: Streaming response
Note over Client: Browser renders HTML immediately
Note over Client: Hydration uses inlined Flight data
For an initial page load, the HTML stream and Flight data are combined. The Flight data is injected into the HTML as inline <script> tags (via createInlinedDataStream), so the browser can hydrate without a second roundtrip. This is the "self-contained HTML" approach — the RSC data needed for hydration travels alongside the HTML in the same response.
For client navigations, only the Flight stream is sent — no HTML needed, because the client-side router directly applies the RSC data to update the component tree.
Partial Prerendering (PPR)
PPR is the most architecturally ambitious feature in the rendering engine. It splits rendering into two phases:
- Static shell — Prerendered at build time, containing all static content and Suspense fallbacks for dynamic sections
- Dynamic holes — Filled at request time, streaming the dynamic content that replaces the fallbacks
The dynamic-rendering.ts module (~1,395 lines) manages the tracking of dynamic access. When a Server Component calls headers(), cookies(), or any other dynamic API during a static prerender, the dynamic rendering system uses React's postpone() API to mark that Suspense boundary as dynamic.
stateDiagram-v2
[*] --> StaticPrerender: Build time
StaticPrerender --> FullyStatic: No dynamic access
StaticPrerender --> PPRShell: Dynamic access detected
PPRShell --> SerializePostponed: React.postpone()
state "Request Time" as RT {
[*] --> LoadShell: Serve cached static HTML
LoadShell --> ResumeRender: Fill dynamic holes
ResumeRender --> StreamDynamic: Stream dynamic content
}
SerializePostponed --> RT: On request
FullyStatic --> ServeCached: Serve from cache
The postponed-state.ts module defines the serialization format for postponed state. There are two types:
DynamicState.DATA— The dynamic access happened during the RSC render (e.g., readingcookies()in a Server Component). The server needs to re-render the RSC tree.DynamicState.HTML— The dynamic access happened during HTML generation (e.g., a Suspense boundary that depends on dynamic data). The server can resume the Fizz render from the postpone point.
Each postponed state includes a RenderResumeDataCache — this is the data that was already computed during the static prerender and can be reused when resuming. The resume data cache lives in server/resume-data-cache/ and stores both the RSC payload fragments and the fetch cache entries that were captured during prerendering.
Tip: PPR is controlled by the
experimental.pprconfig. When enabled, the build prerendering uses React'sprerender()API (notrenderToString()), which supportspostpone(). The runtime then usesresume()to complete the render with request-specific data.
The Rendering Decision Matrix
Putting it all together, the rendering engine makes a cascade of decisions for every request:
| Condition | Rendering Strategy |
|---|---|
| Initial load, no postponed state | Full Fizz HTML stream + inlined Flight data |
| Initial load, with postponed state (PPR) | Serve static shell, resume dynamic holes |
| RSC request (client navigation) | Flight-only payload |
| RSC prefetch request | Partial Flight payload (layouts only, no leaf data) |
| Static generation (build time) | Prerender with potential postpone |
| ISR revalidation | Background re-render, serve stale |
The rendering engine's complexity comes from handling all these cases within a single code path. The renderToHTMLOrFlightImpl function uses the parsed request headers and work store state to branch into the appropriate strategy, but the component tree construction, error handling, and stream management are shared across all paths.
What's Next
We've seen how the server renders App Router pages — building component trees, choosing between HTML and Flight output, and orchestrating the PPR pipeline. But what happens to these Flight payloads when they arrive at the browser? In the next article, we'll explore the client-side router — a Redux-like state machine that manages navigation, layout preservation, and the segment cache that makes PPR prefetching possible.