cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

The client router

Read Next.js 16's client router in its source: the router state tree, soft and hard navigations, the segment cache, prefetching, and hidden pages.

By the end, you can
  • 01
    Follow a click through the routerTrace a Link click from preventDefault to the reducer, the segment cache, and a soft or a hard navigation.
  • 02
    Read what the router keepsRead a recorded router state tree, a route's cache key, and the prefetch requests a page of links makes.
  • 03
    Predict what stays after a navigationSay which pages stay hidden for the Back button, where the scroll goes, what keeps focus, and what screen readers hear.

From a click to a new screen

Linking and navigating showed what a Link does from the outside, and Instant navigations showed what a prefetch fetches. Both are the work of the client router: the part of Next.js that runs in the browser and changes the screen when a visitor follows a link, without loading a whole new page. In Next.js 16.4.3 it lives in packages/next/src/client/components.

The app is the tea shop's tea pages: a list at /teas, and one page per tea at /teas/[tea], under a layout with a link to each tea. Each tea page has a counter that lives in the browser, and a stock line that's made for each request. This is a real Next.js 16.4.3 project, built with next build, run with next start, and opened in real Chrome. Chrome opened /teas, waited until the router's prefetches had answered, clicked Mint tea, brewed a cup, went to Green tea, Lemon tea and Chai, then pressed Back three times. The Steps tab opens at the click on Green tea:

The tea pages, clicked through in real ChromeNext.js
app/teas/[tea]/
app/teas/layout.tsx
import Link from "next/link"; export default function TeasLayout({ children }: LayoutProps<"/teas">) {  return (    <section>      <nav>        <Link href="/teas/mint">Mint tea</Link>        <Link href="/teas/green">Green tea</Link>        <Link href="/teas/lemon">Lemon tea</Link>        <Link href="/teas/chai">Chai</Link>      </nav>      {children}    </section>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teas/greenGreen tea | Tea shop
Mint teaGreen teaLemon teaChai
Green tea

Steep for 2 minutes.

Cups brewed: 0 Brew a cup

In stock today.

Focus
the link “Green tea”
Scrolled
0 px down
Kept hidden by React
Mint teaSteep for 5 minutes.Cups brewed: 1 Brew a cupIn stock today., Pick a tea
Router state in the history entry
["",{"children":["teas",{"children":[["tea","green","d",[]],{"children":["__PAGE__",{},null,null,49314,""]},null,null,49250]},null,null,49250]},null,null,49234]
Announced to screen readers
Green tea | Tea shop

Requests the page made during this step (1), without the files it loaded

  1. GET/teas/green200text/x-component
    rsc
    1
    next-router-state-tree
    %5B%22%22%2C%7B%22children%22%3A%5B%22teas%22%2C%7B%22children%22%3A%5B%5B%22tea%22%2C%22green%22%2C%22d%22%2Cnull%5D%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2C%22refetch%22%2C49314%5D%7D%2Cnull%2Cnull%2C49250%5D%7D%2Cnull%2Cnull%2C49250%5D%7D%2Cnull%2Cnull%2C49234%5D
    next-url
    /teas/mint

In app/teas/layout.tsx, lines 7 to 10 are the four links. At step 5, the address is /teas/green, and the page made one request, a fetch with the header rsc: 1: no new document. That's a soft navigation, a client-side change of page that keeps the layouts the two pages share. Under the page, this example lists two things the screen doesn't show, which the recorder read from Chrome: the router state in the history entry, and the pages kept hidden by React. Mint tea, with its one brewed cup, is one of them.

A few words first. A segment is one part of the address, and one folder of app: /teas/green has the root segment, teas and [tea], and the page is a segment of its own, __PAGE__ (How a route becomes a React tree showed it on the server). The RSC payload is the description of rendered Server Components that the router fetches with rsc: 1. Here are the parts of the router, and the order they work in:

Top row: a Link comes into view, the scheduler runs a prefetch task, and the server answers. Its answer goes into the segment cache. Bottom rows: a click calls navigate, which reads the segment cache and makes a new router state, which LayoutRouter renders as the new screen.Link in viewschedulerserver/_tree, shellsegment cacheclicknavigatereadsrouter stateLayoutRouterrenders the new screen
Before the click, the scheduler fills the segment cache. At the click, navigate reads it and makes a new router state, and LayoutRouter renders it. Only what the cache doesn't have is asked for after the click.
Real-life analogyA theatre's stage crew

A stage crew keeps a plan of what's on stage. Before a cue, they build the next scene's pieces in the wings. At the cue, they swap only what changes; the backdrop stays. They keep the last couple of scenes under a cover, in case the play goes back. When the next act is in another theatre, nobody carries the stage across town: the audience walks there.

In real life: The stage plan: which set pieces are on stage right now
In Next.js: The router state tree
In real life: Pieces for the next scenes, built in the wings before the cue
In Next.js: Prefetched entries in the segment cache
In real life: Changing only the pieces that differ between two scenes
In Next.js: A soft navigation, which keeps the shared layouts
In real life: The last two scenes, kept under a cover backstage
In Next.js: Hidden entries in an Activity boundary
In real life: A play in another theatre: the audience walks over
In Next.js: A hard navigation to another root layout
The main idea

The client router keeps a tree of the segments on screen, fills a cache of segments before the click, and at the click builds the next tree from that cache, fetching only what's missing. When it can't keep the root layout, it hands the address to the browser for a whole page load.

The router state tree

The router describes the screen with one value, the router state tree: the segments on screen, nested the way the folders are. Its type is FlightRouterState, shortened from shared/lib/app-router-types.ts, lines 143 to 183 (the left-out lines are comments):

The router state tree's type, shortened from app-router-types.tsTypeScript
export type FlightRouterState = [
  segment: Segment,
  parallelRoutes: { [parallelRouterKey: string]: FlightRouterState },
  refreshState?: CompressedRefreshState | null,
// …
  refresh?: 'refetch' | 'inside-shared-layout' | 'metadata-only' | null,
// …
  prefetchHints?: number | null,
// …
  renderedSearch?: string,
]

Line 1 says it's a list. Line 2 is the segment, line 3 its children by slot name, each a FlightRouterState of its own: that's what makes it a tree. Lines 4 to 10 are optional extras. After step 5, the recorder read this tree from the history entry. Laid out to read, it's this:

The router state tree after the click on Green tea, recorded in real ChromeJSON
[
  "",
  {
    "children": [
      "teas",
      {
        "children": [
          ["tea", "green", "d", []],
          {
            "children": ["__PAGE__", {}, null, null, 49314, ""]
          },
          null,
          null,
          49250
        ]
      },
      null,
      null,
      49250
    ]
  },
  null,
  null,
  49234
]
  1. Line 2 is the root segment, an empty string: app/layout.tsx. Lines 22 to 24 are its last three slots.
  2. Lines 4 and 5: its children slot holds the segment teas, with its own extras on lines 17 to 19.
  3. Line 8 is the dynamic segment [tea], as a list: the param's name, its value green, its type d (dynamic) and the static folders beside it, none.
  4. Line 10 is the page, all on one line: __PAGE__, no children ({}), two nulls, its hints, and the empty search string the server rendered it with.
Four nodes, each inside the children of the one above: the root segment, an empty string, then teas, then the dynamic segment, a list of tea, green, d and an empty list, then __PAGE__. Labels say which file each node stands for: app/layout.tsx, app/teas/layout.tsx, the [tea] folder, and page.tsx.""app/layout.tsx"teas"app/teas/layout.tsx["tea", "green", "d", []]the [tea] folder, at green"__PAGE__"app/teas/[tea]/page.tsx
The recorded router state at /teas/green: a node for each segment, each holding the next one in its children slot. The dynamic segment is a list: the param's name, its value, its type (d, dynamic) and the static folders beside it (none).

Each slot of a node, and what this recording has in it:

The slots of a FlightRouterState
SlotWhat it holdsIn the recording
1. segmentThe segment's name: a folder, __PAGE__ for the page, or a list for a dynamic folder."teas", and ["tea", "green", "d", []] for [tea].
2. parallelRoutesThe segment's children, by slot name. Most layouts have one slot, children.{ "children": […] } on every node but the page's, which has {}.
3. refreshStateFor a parallel route slot that shows an older URL: that URL, to refresh it.null: the shop has no parallel routes.
4. refreshA marker for the server, sent with a request: "refetch" means start rendering here.null in the history entry; "refetch" on __PAGE__ in the request the click sent.
5. prefetchHintsA number whose bits describe the segment, like 16 for the root layout or above.49234 on the root, which has the bit 16; 49250 below it, without it.
6. renderedSearchOnly on a page: the search string the server rendered it with."": the address has no ?.

Where the tree is kept

The tree is one field of the router's whole state, AppRouterState (router-reducer-types.ts, lines 206 to 258): beside tree, it holds root (the rendered data of each segment, RouteTree nodes in this version), canonicalUrl (the address), pushRef (whether to push a history entry, or load a whole page) and scrollRef (where to scroll). After each navigation, a component called HistoryUpdater writes the tree into the browser's history entry, from app-router.tsx, lines 128 to 153:

HistoryUpdater writes the tree into the history entry, shortened from app-router.tsxTypeScript
const appHistoryState: AppHistoryState = {
  tree,
  renderedSearch,
}
// …
const historyState = {
  ...(pushRef.preserveCustomHistoryState ? window.history.state : {}),
  // Identifier is shortened intentionally.
  // __NA is used to identify if the history entry can be handled by the app-router.
  // __N is used to identify if the history entry can be handled by the old router.
  __NA: true,
  __PRIVATE_NEXTJS_INTERNALS_TREE: appHistoryState,
}
if (
  pushRef.pendingPush &&
  // Skip pushing an additional history entry if the canonicalUrl is the same as the current url.
  // This mirrors the browser behavior for normal navigation.
  createHrefFromUrl(new URL(window.location.href)) !== canonicalUrl
) {
  // This intentionally mutates React state, pushRef is overwritten to ensure additional push/replace calls do not trigger an additional history entry.
  pushRef.pendingPush = false
  window.history.pushState(historyState, '', canonicalUrl)
} else {
  window.history.replaceState(historyState, '', canonicalUrl)
}

Lines 1 to 4 take the tree and the rendered search. Lines 11 and 12 put them in the history state, under __PRIVATE_NEXTJS_INTERNALS_TREE, beside __NA: true, which marks the entry as the App Router's. Lines 14 to 25 push a new history entry for a navigation, or replace the current one. That's where the recorder read the tree: history.state in the page, after each step.

The server gets a copy too. The request the click sent carries the target tree in its next-router-state-tree header, URL-encoded. Decoded, it's the same tree with one change:

The tree the click on Green tea sent, decoded from next-router-state-treeJSON
[
  "",
  {
    "children": [
      "teas",
      {
        "children": [
          ["tea", "green", "d", null],
          {
            "children": ["__PAGE__", {}, null, "refetch", 49314]
          },
          null,
          null,
          49250
        ]
      },
      null,
      null,
      49250
    ]
  },
  null,
  null,
  49234
]

Line 10 has "refetch" in the page's fourth slot: start rendering here. The router already had everything above the page, from the prefetch, so the server rendered only the page. (The header leaves out the page's search string, and line 8 sends null for the static folders.) The request headers and the payload's format are for The RSC payload, earlier in this stage.

The segment cache and the prefetch scheduler

Where did the router find /teas/green's layouts before the click? In the segment cache, the router's store in the browser, in client/components/segment-cache. An entry is one saved record in it, for one route or one segment. It has two maps: one entry per route, holding the route's tree of segments, and one entry per segment, holding that segment's rendered output. A route's key, from segment-cache/cache-key.ts, lines 20 to 31:

A route's cache key: cache-key.ts, lines 20 to 31TypeScript
export function createCacheKey(
  originalHref: string,
  nextUrl: string | null
): RouteCacheKey {
  const originalUrl = new URL(originalHref)
  const cacheKey = {
    pathname: originalUrl.pathname as NormalizedPathname,
    search: originalUrl.search as NormalizedSearch,
    nextUrl: nextUrl as NormalizedNextUrl | null,
  } as RouteCacheKey
  return cacheKey
}

Lines 7 to 9: the pathname, the search string, and the Next-Url, the address the visitor is coming from, which intercepting routes need. The hash isn't in it. A segment's key is its vary path, from vary-path.ts, lines 19 to 33:

What a vary path is: vary-path.ts, lines 19 to 33TypeScript
/**
 * A linked-list of all the params (or other param-like) inputs that a cache
 * entry may vary by. This is used by the CacheMap module to reuse cache entries
 * across different param values. If a param has a value of Fallback, it means
 * the cache entry is reusable for all possible values of that param. See
 * cache-map.ts for details.
 *
 * A segment's vary path is a pure function of a segment's position in a
 * particular route tree and the (post-rewrite) URL that is being queried. More
 * concretely, successive queries of the cache for the same segment always use
 * the same vary path.
 *
 * A route's vary path is simpler: it's comprised of the pathname, search
 * string, and Next-URL header.
 */

Lines 2 to 5: the key lists the params the entry depends on, and a param whose value is Fallback means "any value". That's how one App Shell serves every tea. Each entry is Empty, Pending, Fulfilled or Rejected (EntryStatus, in cache-map.ts, lines 76 to 81), and all of them share one size limit, 50 MB, beyond which the least recently used go (lru.ts, line 14).

Each row is one of the router's stores in the browser, what one entry holds, and its key:

What the client router keeps in the browser
StoreWhat one entry holdsWhat it's keyed by
The route cacheOne entry per address: the route's tree of segments and its hints.The pathname, the search string and the Next-Url (createCacheKey).
The segment cacheOne entry per segment: its rendered output (RSC data) and whether it's partial.A vary path: the segment's place in the route and the params it depends on.
The kept entries of one levelUp to three rendered entries: the visible one and two hidden ones, with their React state.The segment's state key, like green for [tea] at /teas/green.
The history entryThe router state tree and the rendered search, under __PRIVATE_NEXTJS_INTERNALS_TREE.The browser's history: one entry per pushState.

The scheduler fills it

Prefetches don't all go out at once. Each link gets a prefetch task, and a scheduler (scheduler.ts) keeps the tasks in a queue sorted by priority, called a heap. A task works in phases, from lines 228 to 248:

The phases of a prefetch task: scheduler.ts, lines 228 to 248TypeScript
/**
 * Prefetch tasks are processed in phases so that high-leverage work runs
 * before per-link work:
 *
 * - RouteTree: fetch the route's tree structure.
 * - Shell: fetch each segment's shell-stage variant, keyed to be reusable
 *   across all params below the root (root params are kept) — the phase's
 *   target is the conceptual App Shell. Runs if the route can produce a
 *   shell and the feature is enabled. Bounded by
 *   filesystem-route count, not link count — so all Shell prefetches across
 *   queued tasks complete before any Speculative prefetch runs, because
 *   shell responses are shared across every navigation to the same route.
 * - Speculative: fetch the route's concrete per-link segment data.
 *
 * Higher numbers run earlier (matches heap-sort convention).
 */
const enum PrefetchPhase {
  RouteTree = 2,
  Shell = 1,
  Speculative = 0,
}

Line 5: first the route's tree. Lines 6 to 12: then the shell of each segment, keyed for any param value, once for every link to the same route. Line 13: then this link's own data. Lines 17 to 20 give each phase a number, and a higher number runs earlier. Go back to the first example's steps 1 and 2. They have 4 route tree requests, one per tea link, with next-router-segment-prefetch: /_tree, then 1 request for the shell, /teas/$d$tea/__PAGE__ ($d$tea is the [tea] folder with a fallback in place of the value). Nothing else: no tea link asked for its own page data.

Three rows, one per phase of a prefetch task. RouteTree: four requests, one route tree per link, for mint, green, lemon and chai. Shell: one request for the whole [tea] route. Speculative: nothing for these links. A note says at most four requests run at once, or twelve for the link the visitor hovers.RouteTreemintgreenlemonchainext-router-segment-prefetch: /_treeShell/teas/$d$tea/__PAGE__one request for every tea linkSpeculativeper-link datanone for these links
The tea list's prefetches, by phase. Each link's task gets its route's tree first. The App Shell of /teas/[tea] is shared by every tea link, so it's asked for once. No more than four prefetch requests are in flight at a time.

The recorder also asked for /teas/mint's tree itself, with the same headers. Open the Network tab of the first example: the answer is the route's tree, one row per segment, the deepest first:

The route tree of /teas/mint, as the server sent itText
3:[["children",{"s":"__PAGE__","h":49314}]]
2:[["children",{"s":{"n":"tea","t":"d","k":null,"s":[]},"h":49250,"c":"$Q3"}]]
1:[["children",{"s":"teas","h":49250,"c":"$Q2"}]]
0:{"b":"TqaR7jQMWrpmrKw-WnVjH","t":{"t":{"s":"","h":49234,"c":"$Q1"}}}

The RSC payload, earlier in this stage, read this answer row by row. What the router takes from it: one row per segment, with its name ("s") and its hints ("h", the same 49234 on the root, line 4, as in the router state), and no param value. Line 2's [tea] has "k":null, so one tree serves every tea. With it, the router knows which segments a tea page has before it fetches any of them.

Which task runs first

Every time a task is added or answered, the scheduler takes the task at the top of the heap, while there's room on the network, from lines 512 to 525:

How many prefetch requests may be in flight, shortened from scheduler.tsTypeScript
if (task.priority === PrefetchPriority.Intent) {
// …
  return inProgressRequests < 12
}

// The default limit is lower than the limit for a hovered link.
return inProgressRequests < 4

Line 7: four requests at a time. Lines 1 to 3: the task of the link the visitor last hovered or touched, which gets the Intent priority, may go up to twelve. The order of the heap, from lines 2695 to 2716:

Which task the scheduler runs first, shortened from scheduler.tsTypeScript
function compareQueuePriority(a: PrefetchTask, b: PrefetchTask) {
// …
  const priorityDiff = b.priority - a.priority
  if (priorityDiff !== 0) {
    return priorityDiff
  }

  // If the priority is the same, check which phase the prefetch is in — is it
  // prefetching the route tree, or the segments? Route trees are prioritized.
  const phaseDiff = b.phase - a.phase
  if (phaseDiff !== 0) {
    return phaseDiff
  }

  // Finally, check the insertion order. `sortId` is an incrementing counter
  // assigned to prefetches. We want to process the newest prefetches first.
  return b.sortId - a.sortId
}

Line 3: the higher priority first (Intent is 2, Default 1, Background 0, in types.ts, lines 15 to 32). Line 10: then the earlier phase, so route trees before shells. Line 17: then the newest task, the one with the highest sortId.

Viewport prefetching with one IntersectionObserver

How does a link know it's on the screen? The browser's IntersectionObserver calls a function when an element comes into, or leaves, an area around the window. Next.js makes one for the whole app, in links.ts, lines 121 to 127:

One IntersectionObserver for every Link: links.ts, lines 121 to 127TypeScript
// A single IntersectionObserver instance shared by all <Link> components.
const observer: IntersectionObserver | null =
  typeof IntersectionObserver === 'function'
    ? new IntersectionObserver(handleIntersect, {
        rootMargin: '200px',
      })
    : null

Line 5, rootMargin: '200px', grows the area by 200 pixels on every side, so a link counts as visible a little before it scrolls in. Each Link hands its <a> to it from a callback ref, from link.tsx, lines 624 to 636:

A Link registers its anchor, shortened from link.tsxTypeScript
const observeLinkVisibilityOnMount = React.useCallback(
  (element: HTMLAnchorElement | SVGAElement) => {
    if (router !== null) {
      linkInstanceRef.current = mountLinkInstance(
        element,
        formattedHref,
        router,
        fetchStrategy,
        prefetchEnabled,
        setOptimisticLinkStatus,
        ownerStack
      )
    }

Line 4, mountLinkInstance, keeps the anchor and its address in a WeakMap (WeakMap and WeakSet), so a removed link can be forgotten, and calls observer.observe on it. Line 9 passes prefetchEnabled: a Link with prefetch={false} is never observed. When the observer reports, handleIntersect goes through the links, from lines 252 to 269:

handleIntersect: links.ts, lines 252 to 269TypeScript
function handleIntersect(entries: Array<IntersectionObserverEntry>) {
  // Process the entries in reverse order. The prefetch scheduler assigns the
  // highest priority to the most recently scheduled task, so whichever link we
  // schedule *last* wins. When multiple links enter the viewport at once (e.g.
  // on initial load), the observer reports them in document order, so iterating
  // in reverse means the link nearest the top of the document is scheduled last
  // and therefore prioritized. The topmost link isn't guaranteed to be the most
  // important, but as a default heuristic it's more reasonable than prioritizing
  // whichever link happens to be lowest in the document.
  for (let i = entries.length - 1; i >= 0; i--) {
    const entry = entries[i]
    // Some extremely old browsers or polyfills don't reliably support
    // isIntersecting so we check intersectionRatio instead. (Do we care? Not
    // really. But whatever this is fine.)
    const isVisible = entry.intersectionRatio > 0
    onLinkVisibilityChanged(entry.target as HTMLAnchorElement, isVisible)
  }
}

Line 10 goes backwards. Lines 2 to 6 say why: the scheduler runs the newest task first, so scheduling the top link last makes it run first. Line 16 schedules a Default-priority task for each visible link, or cancels it for one that left. In next dev, the function returns at once (lines 271 to 277): no viewport prefetching in development. Here's the 200-pixel margin in a recording. The menu page has a link at the top, one 600 pixels below it, and one 1,400 pixels further down; the window is 640 pixels tall:

Three links at three heightsNext.js
app/gift-boxes/menu/recipes/teas/
app/menu/page.tsx
import Link from "next/link"; export default function MenuPage() {  return (    <main>      <h1>Menu</h1>      <Link href="/teas">Teas</Link>      <div style={{ height: 600 }} />      <Link href="/gift-boxes">Gift boxes</Link>      <div style={{ height: 1400 }} />      <Link href="/recipes">Recipes</Link>      <div style={{ height: 400 }} />    </main>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/menuTea shop
Menu
Teas
Gift boxes
Recipes
Focus
nothing: the page itself
Scrolled
0 px down

Requests the page made during this step (12), files and other sites included

  1. GET/menu200a whole page
  2. GET/_next/static/chunks/1x6obpmlu1_c2.js200application/javascript
  3. GET/_next/static/chunks/06bs-nf19b2js.js200application/javascript
  4. GET/_next/static/chunks/2ki52vdnbo2ft.js200application/javascript
  5. GET/_next/static/chunks/3l_qmp-61z4yc.js200application/javascript
  6. GET/_next/static/chunks/turbopack-3-ry682tkyiqk.js200application/javascript
  7. GET/_next/static/chunks/1tq6ppe__m8m-.js200application/javascript
  8. GET/_next/static/chunks/21poqdgvjdll4.js200application/javascript
  9. GET/teas200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /menu
  10. GET/gift-boxes200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /menu
  11. GET/teas200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /teas/__PAGE__
    next-url
    /menu
  12. GET/gift-boxes200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /gift-boxes/__PAGE__
    next-url
    /menu

This example lists every request, scripts included, so look for the fetch ones. Step 1 prefetched /teas and /gift-boxes: the Gift boxes link, on line 9 of app/menu/page.tsx, starts about 700 pixels down, under the bottom of the window, and still inside the margin. Not /recipes, on line 11, far below. Step 3 scrolled 1500 pixels down, and the prefetches for /recipes came then.

Hovering is a second way in. Its handler, from link.tsx, lines 704 to 729:

Hovering a Link, shortened from link.tsxTypeScript
onMouseEnter(e) {
// …
  if (!router) {
    return
  }
  if (!prefetchEnabled || process.env.NODE_ENV === 'development') {
    return
  }

  const upgradeToDynamicPrefetch = unstable_dynamicOnHover === true
  onNavigationIntent(
    e.currentTarget as HTMLAnchorElement | SVGAElement,
    upgradeToDynamicPrefetch
  )
},

Line 6: not with prefetch={false}, and not in next dev. Line 11, onNavigationIntent, schedules the link's task again with the Intent priority, so it jumps the queue. If the cache still has what the task needs, it finishes without a request.

Hidden entries and the Back button

Back to the tea pages. Look at Kept hidden by React under the page in each step of the first example. At step 5, on Green tea, it's Mint tea and Pick a tea. At step 7, on Chai, it's Lemon tea and Green tea: Mint tea and the list are gone. That's the router's back-forward cache, called bfcache in the source, in bfcache-state-manager.ts:

useRouterBFCache keeps the most recent entries, shortened from bfcache-state-manager.tsTypeScript
// When the flag is disabled, only track the currently active tree
const MAX_BF_CACHE_ENTRIES = process.env.__NEXT_CACHE_COMPONENTS ? 3 : 1
// …
  const newActiveEntry: RouterBFCacheEntry = {
    tree: activeTree,
    renderTree: activeRenderTree,
    stateKey: activeStateKey,
    next: null,
  }
// …
  let n = 1
  let oldEntry: RouterBFCacheEntry | null = prevActiveEntry
  let clonedEntry: RouterBFCacheEntry = newActiveEntry
  while (oldEntry !== null && n < MAX_BF_CACHE_ENTRIES) {
    if (oldEntry.stateKey === activeStateKey) {
// …
      clonedEntry.next = oldEntry.next
      break
    } else {
      // Clone the entry and append it to the list.
      n++
      const entry: RouterBFCacheEntry = {
        tree: oldEntry.tree,
        renderTree: oldEntry.renderTree,
        stateKey: oldEntry.stateKey,
        next: null,
      }
      clonedEntry.next = entry
      clonedEntry = entry
    }
    oldEntry = oldEntry.next
  }
  1. Line 2: MAX_BF_CACHE_ENTRIES is 3 with Cache Components on, and 1 without it. The count includes the visible entry, so at most two are hidden.
  2. Lines 4 to 9: the entry for the segment now on screen goes first in a new list.
  3. Lines 14 to 19: the old list follows, entry by entry, until the new list has 3. If the walk meets the entry now on screen, the rest of the old list is reused as it is, since the list didn't grow.
  4. Lines 21 to 29 copy each old entry into the new list instead of changing it: React state must never be changed in place.

This runs in LayoutRouter, once for each slot of each layout, and the kept entries become Activity boundaries, from layout-router.tsx, lines 583 to 752:

Each kept entry in an Activity boundary, shortened from layout-router.tsxTSX
const activeSegment = activeTree[0]
const activeStateKey = createSegmentKey(activeSegment)
// …
let bfcacheEntry: RouterBFCacheEntry | null = useRouterBFCache(
  activeTree,
  activeRenderTree!,
  activeStateKey
)
// …
  if (process.env.__NEXT_CACHE_COMPONENTS) {
    child = (
      <Activity
        name={debugNameToDisplay}
        key={stateKey}
        mode={stateKey === activeStateKey ? 'visible' : 'hidden'}
      >
        {child}
      </Activity>
    )

Line 2: the key of an entry is its segment, so green and lemon are two entries of the same [tea] folder. Line 15: the visible one gets mode="visible", the others "hidden", which keeps their state and their DOM but hides them with display: none. That's what the recorder found. Try other orders:

Playground: which pages stay hidden?
The visits so far, in orderText
1. /teas

Kept in the teas layout's slot, most recent first

  1. Pick a tea: visible
Try it yourself
Visible: Pick a tea. Hidden: nothing.

Nothing fell off on this visit. A hidden entry keeps its React state and its DOM, so coming back shows it as it was.

This runs a small model of useRouterBFCache in this page; it isn't Next.js's code. The recorded steps (Pick a tea, Mint tea, Green tea, Lemon tea, Chai, then Lemon tea, Green tea, Mint tea) give the same lists as real Chrome. Reset goes back to the tea list, with Cache Components on.

The recorded Back steps made no request. Back is a popstate event, and its handler, from app-router.tsx, lines 89 to 101, hands the entry's tree to traverse, which dispatches a restore action:

The Back button: app-router.tsx, lines 89 to 101TypeScript
function handlePopState(state: PopStateEvent['state']): void {
  if (!state) {
    // TODO-APP: this case only happens when pushState/replaceState was called outside of Next.js. It should probably reload the page in this case.
    return
  }

  // This case happens when the history entry was pushed by the `pages` router.
  if (!state.__NA) {
    window.location.reload()
    return
  }

  traverse(window.location.href, state.__PRIVATE_NEXTJS_INTERNALS_TREE)

Lines 8 to 11: an entry without __NA, written by the Pages Router, reloads the page. Line 13 passes __PRIVATE_NEXTJS_INTERNALS_TREE on. The restore reducer builds the tree from that history tree and the data the router holds (restore-reducer.ts, lines 69 to 87), then calls spawnDynamicRequests for anything it still needs (lines 88 to 109): that may be answered from cached data, or fetched from the server like any navigation. In this recording, everything was still there. Step 8 showed Lemon tea as it was; step 10 showed Mint tea with Cups brewed: 0: its entry was gone, so React made the counter again, but the page's data was still in the router, so no request went out.

Scroll, focus and the route announcer

Every LayoutRouter slot is wrapped in a ScrollHandler (How a route becomes a React tree showed it at the top of the nesting). After a navigation commits, it decides about the scroll, from layout-router.tsx, lines 155 to 243:

Where the scroll goes, shortened from layout-router.tsxTypeScript
const scrollRef = scrollHandlerRef.forceScroll
  ? scrollHandlerRef.scrollRef
  : renderTree.data.scrollRef
if (scrollRef === null || !scrollRef.current) return
// …
    // Mark as scrolled so no other segment scrolls for this navigation.
    scrollRef.current = false

    // This handler intentionally leaves focus untouched; resetting focus on
    // navigation is deferred.
// …
    // If the element's top edge is already in the viewport, exit early.
    if (initialTargetState === ScrollTargetState.InViewport) {
      return
    }

    // Otherwise, try scrolling go the top of the document to be backward compatible with pages
    // scrollIntoView() called on `<html/>` element scrolls horizontally on chrome and firefox (that shouldn't happen)
    // We could use it to scroll horizontally following RTL but that also seems to be broken - it will always scroll left
    // scrollLeft = 0 also seems to ignore RTL and manually checking for RTL is too much hassle so we will scroll just vertically
    htmlElement.scrollTop = 0
  1. Lines 1 to 4: only segments the navigation created hold a live scrollRef; a shared layout's handler returns.
  2. Line 7: the first one to run marks the ref as used, so one navigation scrolls at most once.
  3. Lines 9 and 10: focus is left alone on purpose.
  4. Lines 13 to 15: if the new segment's top is already in the window, nothing scrolls.
  5. Line 21: otherwise, scroll to the top of the document (and then into view, if that isn't enough).

In the first example, the tea pages start near the top of the window, so every step shows 0 px down. Linking and navigating recorded a long page, where a click scrolled back to the top, and scroll={false} kept the scroll.

Focus is in the Focus line of each step. After the click on Green tea, focus is still on the Green tea link, in the layout. After the three Back steps, it's on Chai, the last link clicked, though the page on screen is Mint tea. Next.js doesn't move focus; the browser may reset it when the focused element is hidden or removed, as Accessibility in Next.js recorded for a link inside the old page. Here the links are in the layout, which stays, so they kept it. Screen reader users hear the change from the route announcer, from app-router-announcer.tsx, lines 47 to 67:

The route announcer: app-router-announcer.tsx, lines 47 to 67TypeScript
useEffect(() => {
  let currentTitle = ''
  if (document.title) {
    currentTitle = document.title
  } else {
    const pageHeader = document.querySelector('h1')
    if (pageHeader) {
      currentTitle = pageHeader.innerText || pageHeader.textContent || ''
    }
  }

  // Only announce the title change, but not for the first load because screen
  // readers do that automatically.
  if (
    previousTitle.current !== undefined &&
    previousTitle.current !== currentTitle
  ) {
    setRouteAnnouncement(currentTitle)
  }
  previousTitle.current = currentTitle
}, [tree])

Line 21: it runs when the router state tree changes. Lines 3 to 10: it takes the document's title, or the first <h1> if there's no title. Lines 14 to 19: it announces only a change, never the first load, which screen readers announce anyway. The text goes into a hidden element with aria-live="assertive", in a shadow root of its own (lines 8 to 28 of the file). Step 5 announced Green tea | Tea shop. The two-root-layout shop has no titles, so its step 3 announced the <h1>, About us. Accessibility in Next.js shows how to give each page a clear title and move focus yourself when a page needs it.

Internals, not an API

FlightRouterState's slots, __PRIVATE_NEXTJS_INTERNALS_TREE, the next-router-… headers, /_tree, the prefetch phases, the limits of 4 and 12, the 200-pixel margin and MAX_BF_CACHE_ENTRIES are Next.js 16.4.3's internals, from this recorded build. Any release can change them. What's documented is the behavior: prefetching in the viewport and on hover, soft navigations that keep shared layouts, a whole page load across root layouts, the scroll rule, and the announcer.

Watching the router in your own app

In a production build of your app (next build and next start; next dev doesn't prefetch on view), you can watch all of this without reading the source. Use it to learn and to debug, never from your app's code:

  • In the browser's Network tab, filter by _rsc: each prefetch has next-router-prefetch, and a route tree has next-router-segment-prefetch: /_tree.
  • A click that shows a document request in the Network tab was a hard navigation; look for a second root layout.
  • In the console, history.state.__PRIVATE_NEXTJS_INTERNALS_TREE.tree is the router state tree of the current entry.
  • In the Elements panel, a page kept for the Back button is still there, with display: none.

Sort what each part of the router does: which part does it?

Which part of the router does it?
  • Noticing that a link came within 200 pixels of the window
  • Stopping the browser from loading a whole page on a plain click
  • Asking the server for a route's tree with next-router-segment-prefetch: /_tree
  • Making a link's prefetch wait while four requests are in flight
  • Calling location.assign when the root layout changes
  • Keeping a page you left in a hidden Activity
Try it yourself
0 of 6 correct

Sort each job: Link and links.ts, the scheduler and the segment cache, or AppRouter and LayoutRouter.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "A click on a Link loads a new page and swaps the body." No. It calls preventDefault(), and the router renders the next tree in the same document, fetching an RSC payload only for what it doesn't have.
  • "A different param value means a whole page load." No. /teas/mint to /teas/green was soft. The router gives up on a soft navigation when the root layout changes, or the target is the global not-found page, or for an external address from router.push, or while a redirect's meta-refresh tag is on the page. (A Link to another site never reaches the router: the browser follows it.)
  • "Every link prefetches its whole page." No. Each link gets its route's tree, and the App Shell is fetched once per route; the recorded tea links fetched nothing of their own.
  • "Next.js keeps three hidden pages." No. It keeps three entries per slot, counting the visible one: the page on screen and up to two hidden ones.
  • "Next.js moves focus to the new page." No. Version 16.4.3 doesn't move focus (the browser may reset it if the focused element is hidden or removed), and the route announcer reads the new title.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints. Some ask you to find a line in Next.js's source, and the excerpts above hold it.

Exercise 1 · Warm-upRead the router state tree

This is the recorded router state tree after the click on Green tea. What value does the [tea] segment hold?

Starter codeJSON
[
  "",
  {
    "children": [
      "teas",
      {
        "children": [
          ["tea", "green", "d", []],
          {
            "children": ["__PAGE__", {}, null, null, 49314, ""]
          },
          null,
          null,
          49250
        ]
      },
      null,
      null,
      49250
    ]
  },
  null,
  null,
  49234
]

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upPredict the viewport prefetches

    Chrome opens this page in a window 640 pixels tall. Which link is not prefetched until the visitor scrolls?

    Starter codeTSX
    import Link from "next/link";
    
    export default function MenuPage() {
      return (
        <main>
          <h1>Menu</h1>
          <Link href="/teas">Teas</Link>
          <div style={{ height: 600 }} />
          <Link href="/gift-boxes">Gift boxes</Link>
          <div style={{ height: 1400 }} />
          <Link href="/recipes">Recipes</Link>
          <div style={{ height: 400 }} />
        </main>
      );
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind it in the source: a new root layout

      Which function decides that a navigation goes into another root layout?

      Starter codeTypeScript
      if (!doesRouteStructureMatch(oldRenderTree, newRouteTree)) {
      // …
        if (
      // …
          ((newRouteTree.prefetchHints & PrefetchHint.IsRootLayoutOrAbove) !== 0 &&
            isNavigatingToNewRootLayout(oldRenderTree, newRouteTree)) ||
      // …
          newSegment === NOT_FOUND_SEGMENT_KEY
        ) {
          return null
        }

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticePredict the fresh page

        With Cache Components on, a visitor goes through these steps. After the last Back, which tea page shows a fresh counter?

        Starter codeText
        /teas
        /teas/mint   (brew a cup)
        /teas/green
        /teas/lemon
        /teas/chai
        Back
        Back
        Back

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFind it in the source: the limit

          How many prefetch requests may be in flight before a task that isn't the hovered link's has to wait?

          Starter codeTypeScript
          if (task.priority === PrefetchPriority.Intent) {
          // …
            return inProgressRequests < 12
          }
          
          // The default limit is lower than the limit for a hovered link.
          return inProgressRequests < 4

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengePredict the focus

            After these steps on the tea pages, what has keyboard focus?

            Starter codeText
            click Mint tea
            click Brew a cup
            click Green tea
            click Lemon tea
            click Chai
            Back
            Back
            Back

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask which part of the router acts: the Link, the scheduler and its cache, or the router's state and LayoutRouter.

              The client router quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does a plain click on a Link to another page of the same app do first?

                Choose an answer to see the explanation.

              2. Question 2 of 7Where does Next.js 16.4.3 keep the router state tree that the Back button uses?

                Choose an answer to see the explanation.

              3. Question 3 of 7When does the client router give up on a soft navigation?

                Choose an answer to see the explanation.

              4. Question 4 of 7What goes into the route cache's key in Next.js 16.4.3?

                Choose an answer to see the explanation.

              5. Question 5 of 7Four links come into view at the same time, at the same priority. Which one's task does the scheduler run first?

                Choose an answer to see the explanation.

              6. Question 6 of 7With Cache Components on, how many entries does one level of the route keep?

                Choose an answer to see the explanation.

              7. Question 7 of 7After a soft navigation, where does keyboard focus go in Next.js 16.4.3?

                Choose an answer to see the explanation.

              Key takeaways

              • The router state tree, FlightRouterState, lists the segments on screen, nested by slot. It's kept in the history entry and sent to the server with a "refetch" marker where rendering should start.
              • A Link click calls preventDefault() and dispatches a navigate action in a transition; the reducer builds the next tree from the segment cache.
              • A navigation is soft unless the root layout changes, the target is the global not-found page, the address from router.push is on another site, or a redirect's meta-refresh tag is on the page: then mpaNavigation makes AppRouter call location.assign. A Link to another site is left to the browser.
              • The segment cache keeps routes by pathname, search and Next-Url, and segments by vary path. A scheduler fills it in phases: route trees, shells, then per-link data, four requests at a time.
              • One IntersectionObserver with a 200-pixel margin watches every Link, in production only; hovering raises a link's priority.
              • Each slot keeps three entries, counting the visible one, in Activity boundaries. Next.js doesn't move focus, and the route announcer reads the new title.

              Remember the one-liner.
              The router keeps a tree of what's on screen, fills its cache before the click, and at the click builds the next tree from it.

              Coming next: Caching under the hood. It reads how 'use cache' builds its keys, where entries are stored, and how tags expire them.

              CompleteFrontend Clear concepts. Working examples.