Read OSS

Inside the App Router Rendering Engine: RSC, Streaming, and PPR

Advanced

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.ts suffix on these files is significant. It indicates that these modules are shared across bundler boundaries — they use import attributes like with { '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:

  1. Fizz (react-dom/server) — Renders React elements to an HTML stream with Suspense boundary support
  2. 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 HTML
  • renderToFlightStream — Serializes RSC data for client navigation
  • createInlinedDataStream — Injects Flight data as <script> tags into the HTML stream
  • continueFizzStream — 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:

  1. Static shell — Prerendered at build time, containing all static content and Suspense fallbacks for dynamic sections
  2. 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., reading cookies() 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.ppr config. When enabled, the build prerendering uses React's prerender() API (not renderToString()), which supports postpone(). The runtime then uses resume() 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.