Read OSS

The Client-Side Router: Navigation, Caching, and State Management

Advanced

Prerequisites

  • Articles 1-3: Architecture, server lifecycle, and App Router rendering
  • React context and useReducer patterns
  • Browser History API (pushState, popstate)
  • Understanding of RSC Flight payload format from Article 3

The Client-Side Router: Navigation, Caching, and State Management

When a user clicks a link in a Next.js App Router application, the page doesn't reload. Instead, a sophisticated client-side state machine fetches an RSC Flight payload from the server, applies it to a cached tree of React elements, and updates only the segments that changed — preserving layout state, scroll position, and React component state in untouched segments. This article explores how that works.

AppRouter: The Root Component

The AppRouter component in app-router.tsx is the root of the App Router's client-side tree. It sets up:

  1. The router reducer (state machine)
  2. History event listeners (popstate)
  3. Context providers that power navigation hooks
  4. Error boundaries for global error handling
flowchart TD
    AppRouter["AppRouter Component"] --> ActionQueue["useActionQueue()\n(reducer state)"]
    AppRouter --> History["HistoryUpdater\n(pushState/popstate)"]
    AppRouter --> Providers["Context Providers"]
    AppRouter --> ErrorBoundary["RootErrorBoundary"]

    Providers --> AppRouterContext["AppRouterContext\n(router instance)"]
    Providers --> LayoutRouterContext["LayoutRouterContext\n(segment tree)"]
    Providers --> PathnameContext["PathnameContext"]
    Providers --> SearchParamsContext["SearchParamsContext"]

    ActionQueue --> Reducer["clientReducer()\n(router-reducer.ts)"]

The HistoryUpdater component is notable — it's a React component whose sole job is to call window.history.pushState() or replaceState() in response to state changes. By making this a component, it runs during React's commit phase, keeping history updates synchronized with the rendered UI.

The initial state comes from the server-rendered page. The server sends down an initial FlightRouterState tree and a CacheNode tree (as we saw in Article 3 via inlined <script> tags). The client-side router hydrates from this initial state and then manages all subsequent updates.

The Router Reducer State Machine

The heart of the client-side router is the clientReducer function. It follows a Redux-like pattern — a pure function that takes the current state and an action, and returns new state:

function clientReducer(
  state: ReadonlyReducerState,
  action: ReducerActions
): ReducerState {
  switch (action.type) {
    case ACTION_NAVIGATE:
      return navigateReducer(state, action)
    case ACTION_SERVER_PATCH:
      return serverPatchReducer(state, action)
    case ACTION_RESTORE:
      return restoreReducer(state, action)
    case ACTION_REFRESH:
      return refreshReducer(state, action)
    case ACTION_HMR_REFRESH:
      return hmrRefreshReducer(state)
    case ACTION_SERVER_ACTION:
      return serverActionReducer(state, action)
    default:
      throw new Error('Unknown action')
  }
}

Each action represents a distinct navigation scenario:

stateDiagram-v2
    [*] --> Idle: Initial render

    Idle --> Navigate: ACTION_NAVIGATE\n(Link click, router.push)
    Idle --> Restore: ACTION_RESTORE\n(Browser back/forward)
    Idle --> Refresh: ACTION_REFRESH\n(router.refresh())
    Idle --> ServerAction: ACTION_SERVER_ACTION\n('use server' call)
    Idle --> HMRRefresh: ACTION_HMR_REFRESH\n(Dev hot reload)

    Navigate --> FetchFlight: Fetch RSC payload
    FetchFlight --> ServerPatch: ACTION_SERVER_PATCH\n(Apply response)
    ServerPatch --> Idle: Update tree + cache

    Restore --> Idle: Restore from history state

    Refresh --> FetchFlight
    ServerAction --> FetchFlight

    HMRRefresh --> Idle: Force re-render

There's a subtle but important architectural choice here: the reducer is also compiled for the server (via serverReducer), but it's a no-op. This enables the same router component code to be used during SSR without errors, while the actual state management only activates in the browser. The tree-shaking comment at line 60 explains: "we don't run the client reducer on the server, so we use a noop function for better tree shaking."

Fetching RSC Payloads for Navigation

When the navigateReducer runs, it triggers a server request for the new route's RSC payload. The fetch-server-response.ts module handles this:

sequenceDiagram
    participant Router as Client Router
    participant Fetch as fetch-server-response
    participant Server as Next.js Server
    participant Flight as React Flight Client

    Router->>Fetch: Navigate to /dashboard
    Fetch->>Server: GET /dashboard
    Note over Fetch,Server: Headers: RSC: 1, Next-Router-State-Tree: [current tree], Next-Url: /dashboard
    Server-->>Fetch: Flight payload (binary stream)
    Fetch->>Flight: createFromFetch(response)
    Flight-->>Router: NavigationFlightResponse
    Router->>Router: Apply to CacheNode tree

The request includes several special headers that the server uses to determine what to send back:

  • RSC: 1 — Signals this is an RSC request (return Flight, not HTML)
  • Next-Router-State-Tree — The client's current FlightRouterState, so the server can compute a diff
  • Next-Url — The current URL, used for middleware evaluation
  • Next-Router-Prefetch — Set for prefetch requests, which return only layout data

The response is parsed using React's createFromFetch() API from react-server-dom-webpack/client. This function reads the binary Flight stream and reconstructs a React element tree that can be rendered by the client-side React instance.

Tip: The Flight payload is not JSON — it's React's proprietary binary format that can represent React elements, client references, promises, and streaming data. You can't read it with response.json(). The createFromFetch and createFromReadableStream functions are the only way to decode it.

Layout Preservation and the CacheNode Tree

The most user-visible feature of the App Router is layout preservation — when you navigate from /dashboard/analytics to /dashboard/settings, the dashboard layout maintains its state (scroll position, form inputs, component state). This works through the CacheNode tree:

export type CacheNode = {
  rsc: React.ReactNode        // Full RSC data
  prefetchRsc: React.ReactNode // Prefetched static shell
  prefetchHead: HeadData | null
  head: HeadData
  slots: Record<string, CacheNode> | null
  // ...
}
graph TD
    Root["CacheNode: /"] --> Dashboard["CacheNode: dashboard"]
    Root --> Settings["CacheNode: settings"]

    Dashboard --> Analytics["CacheNode: analytics\n(page content)"]
    Dashboard --> DashSettings["CacheNode: settings\n(page content)"]

    style Analytics fill:#9f9,stroke:#333
    style DashSettings fill:#ff9,stroke:#333

    classDef active fill:#9f9
    classDef navigating fill:#ff9

The layout-router.tsx component is responsible for rendering each level of the nested layout tree. It receives the CacheNode for its segment via context and renders the rsc content — or falls back to prefetchRsc if the full data hasn't arrived yet (using React's useDeferredValue).

When a navigation happens, the router compares the current and next FlightRouterState trees. Segments that didn't change keep their existing CacheNode — React never re-renders them. Only the changed segments get new CacheNode entries with fresh RSC data from the Flight payload. This is structural sharing at the route segment level.

The slots field in CacheNode maps parallel route keys to their children. A segment like dashboard might have slots for children (the default slot) and @modal (a parallel route). Each slot independently maintains its cache tree.

Segment Cache for PPR Prefetching

The segment-cache/ directory implements per-segment caching for PPR-enabled routes. When PPR is active, prefetching doesn't fetch the entire route's Flight data — it fetches individual route segments independently:

flowchart TD
    Link["<Link> visible\nin viewport"] --> Prefetch["prefetch()"]
    Prefetch --> SegmentCache["Segment Cache"]

    SegmentCache --> RootSegment["/ segment\n(static shell)"]
    SegmentCache --> DashSegment["/dashboard segment\n(static shell)"]
    SegmentCache --> PageSegment["/dashboard/analytics segment\n(may be dynamic)"]

    Navigate["User clicks link"] --> CheckCache["Check segment cache"]
    CheckCache --> Hit["Cache hit:\nServe static shell instantly"]
    CheckCache --> Miss["Cache miss:\nFetch dynamic data"]
    Hit --> Merge["Merge shells +\ndynamic data"]
    Miss --> Merge
    Merge --> Render["Render updated tree"]

The segment cache module consists of several files:

File Purpose
cache.ts Core cache data structures and operations
cache-key.ts Cache key generation from route segments
prefetch.ts Prefetch scheduling and execution
navigation.ts Cache-aware navigation logic
scheduler.ts Priority-based prefetch scheduling
lru.ts LRU eviction for memory management

This is one of the most sophisticated parts of the client-side router. Rather than caching entire routes, it caches individual segments — so the static parts of a PPR route can be cached and reused across different pages that share layouts, while dynamic segments are fetched on demand.

The public navigation APIs are implemented via React context in navigation.ts:

flowchart TD
    AppRouter["AppRouter"] --> Ctx1["AppRouterContext\n(provides: router instance)"]
    AppRouter --> Ctx2["PathnameContext\n(provides: pathname string)"]
    AppRouter --> Ctx3["SearchParamsContext\n(provides: URLSearchParams)"]
    AppRouter --> Ctx4["PathParamsContext\n(provides: dynamic params)"]

    Ctx1 --> useRouter["useRouter()\nreturns: push, replace, refresh, back, forward, prefetch"]
    Ctx2 --> usePathname["usePathname()\nreturns: string"]
    Ctx3 --> useSearchParams["useSearchParams()\nreturns: ReadonlyURLSearchParams"]
    Ctx4 --> useParams["useParams()\nreturns: Params object"]

The useRouter() hook returns the router instance created in app-router-instance.ts. This module creates the publicAppRouterInstance — an object with methods like push(), replace(), refresh(), back(), and prefetch(). Each of these methods dispatches an action to the router reducer.

An interesting pattern in navigation.ts is the dual-environment handling. When running on the server (SSR), useSearchParams and useParams need to trigger dynamic rendering bailouts for PPR. This is handled by conditionally importing useDynamicRouteParams and useDynamicSearchParams only on the server side:

const useDynamicRouteParams =
  typeof window === 'undefined'
    ? require('../../server/app-render/dynamic-rendering').useDynamicRouteParams
    : undefined

This pattern — using typeof window checks to conditionally require server modules — appears throughout the client code. It ensures that server-only code is tree-shaken out of the browser bundle.

Tip: useRouter() in the App Router returns a different object than useRouter() from next/router (Pages Router). They have different APIs and are backed by completely separate state management systems. If you're migrating from Pages Router, this is a common source of confusion.

The Router Instance and Imperative Navigation

The app-router-instance.ts module creates the AppRouterActionQueue — the dispatcher that coordinates between the React state and the reducer:

export type AppRouterActionQueue = {
  state: AppRouterState
  dispatch: (payload: ReducerActions, setState: DispatchStatePromise) => void
  action: (state: AppRouterState, action: ReducerActions) => ReducerState
}

This queue handles action serialization — if multiple navigations happen quickly (e.g., the user rapidly clicks different links), actions are queued and processed sequentially. The queue also integrates with React's startTransition API, ensuring that navigation state updates are treated as non-urgent transitions that don't block user input.

The prefetch() method on the router instance connects to the segment cache system. When <Link> components become visible in the viewport, they trigger prefetching via prefetchWithSegmentCache(). The prefetch scheduler prioritizes based on viewport visibility and user interaction patterns.

What's Next

We've now traced the App Router's complete lifecycle — from server-side rendering (Article 3) through client-side navigation and state management. In the next article, we'll turn to the build system: how next build discovers routes, generates code, configures three separate webpack compilations, and produces the manifests that bridge the server/client module graphs.