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.
- 01Follow a click through the routerTrace a Link click from preventDefault to the reducer, the segment cache, and a soft or a hard navigation.
- 02Read what the router keepsRead a recorded router state tree, a route's cache key, and the prefetch requests a page of links makes.
- 03Predict 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:
app/teas/layout.tsximport 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> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Steep for 2 minutes.
Cups brewed: 0
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
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:
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.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 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):
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:
[
"",
{
"children": [
"teas",
{
"children": [
["tea", "green", "d", []],
{
"children": ["__PAGE__", {}, null, null, 49314, ""]
},
null,
null,
49250
]
},
null,
null,
49250
]
},
null,
null,
49234
]- Line 2 is the root segment, an empty string:
app/layout.tsx. Lines 22 to 24 are its last three slots. - Lines 4 and 5: its
childrenslot holds the segmentteas, with its own extras on lines 17 to 19. - Line 8 is the dynamic segment
[tea], as a list: the param's name, its valuegreen, its typed(dynamic) and the static folders beside it, none. - Line 10 is the page, all on one line:
__PAGE__, no children ({}), twonulls, its hints, and the empty search string the server rendered it with.
/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:
| Slot | What it holds | In the recording |
|---|---|---|
1. segment | The segment's name: a folder, __PAGE__ for the page, or a list for a dynamic folder. | "teas", and ["tea", "green", "d", []] for [tea]. |
2. parallelRoutes | The segment's children, by slot name. Most layouts have one slot, children. | { "children": […] } on every node but the page's, which has {}. |
3. refreshState | For a parallel route slot that shows an older URL: that URL, to refresh it. | null: the shop has no parallel routes. |
4. refresh | A 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. prefetchHints | A 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. renderedSearch | Only 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:
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:
[
"",
{
"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:
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:
/**
* 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:
| Store | What one entry holds | What it's keyed by |
|---|---|---|
| The route cache | One entry per address: the route's tree of segments and its hints. | The pathname, the search string and the Next-Url (createCacheKey). |
| The segment cache | One 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 level | Up 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 entry | The 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:
/**
* 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.
/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:
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:
if (task.priority === PrefetchPriority.Intent) {
// …
return inProgressRequests < 12
}
// The default limit is lower than the limit for a hovered link.
return inProgressRequests < 4Line 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:
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:
// A single IntersectionObserver instance shared by all <Link> components.
const observer: IntersectionObserver | null =
typeof IntersectionObserver === 'function'
? new IntersectionObserver(handleIntersect, {
rootMargin: '200px',
})
: nullLine 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:
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:
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:
app/menu/page.tsximport 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> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (12), files and other sites included
GET/menu200a whole pageGET/_next/static/chunks/1x6obpmlu1_c2.js200application/javascriptGET/_next/static/chunks/06bs-nf19b2js.js200application/javascriptGET/_next/static/chunks/2ki52vdnbo2ft.js200application/javascriptGET/_next/static/chunks/3l_qmp-61z4yc.js200application/javascriptGET/_next/static/chunks/turbopack-3-ry682tkyiqk.js200application/javascriptGET/_next/static/chunks/1tq6ppe__m8m-.js200application/javascriptGET/_next/static/chunks/21poqdgvjdll4.js200application/javascriptGET/teas200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /menu
GET/gift-boxes200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /menu
GET/teas200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /teas/__PAGE__
- next-url
- /menu
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:
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.
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:
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- Lines 1 to 4: only segments the navigation created hold a live
scrollRef; a shared layout's handler returns. - Line 7: the first one to run marks the ref as used, so one navigation scrolls at most once.
- Lines 9 and 10: focus is left alone on purpose.
- Lines 13 to 15: if the new segment's top is already in the window, nothing scrolls.
- 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:
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.
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 hasnext-router-prefetch, and a route tree hasnext-router-segment-prefetch: /_tree. - A click that shows a
documentrequest in the Network tab was a hard navigation; look for a second root layout. - In the console,
history.state.__PRIVATE_NEXTJS_INTERNALS_TREE.treeis 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?
- 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.assignwhen the root layout changes - Keeping a page you left in a hidden
Activity
Sort each job: Link and links.ts, the scheduler and the segment cache, or AppRouter and LayoutRouter.
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/mintto/teas/greenwas 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 fromrouter.push, or while a redirect's meta-refresh tag is on the page. (ALinkto 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.
This is the recorded router state tree after the click on Green tea. What value does the [tea] segment hold?
[
"",
{
"children": [
"teas",
{
"children": [
["tea", "green", "d", []],
{
"children": ["__PAGE__", {}, null, null, 49314, ""]
},
null,
null,
49250
]
},
null,
null,
49250
]
},
null,
null,
49234
]green. Line 8 is ["tea", "green", "d", []]: the param tea, its value green, the type d (dynamic), and no static folders beside it.
Chrome opens this page in a window 640 pixels tall. Which link is not prefetched until the visitor scrolls?
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>
);
}
Recipes. Gift boxes starts about 700 pixels down, inside the 200-pixel margin, so it was prefetched with Teas. Recipes is 1,400 pixels further down; the recording shows its prefetch only after the scroll.
Which function decides that a navigation goes into another root layout?
if (!doesRouteStructureMatch(oldRenderTree, newRouteTree)) {
// …
if (
// …
((newRouteTree.prefetchHints & PrefetchHint.IsRootLayoutOrAbove) !== 0 &&
isNavigatingToNewRootLayout(oldRenderTree, newRouteTree)) ||
// …
newSegment === NOT_FOUND_SEGMENT_KEY
) {
return null
}isNavigatingToNewRootLayout, in router-reducer/is-navigating-to-new-root-layout.ts. It walks both trees down to the root layout and compares their segments, ignoring param values. When it says yes, startPPRNavigation returns null and the navigation is hard.
With Cache Components on, a visitor goes through these steps. After the last Back, which tea page shows a fresh counter?
/teas
/teas/mint (brew a cup)
/teas/green
/teas/lemon
/teas/chai
Back
Back
BackMint tea. When Chai came on screen, the list held Chai, Lemon tea and Green tea, so Mint tea's entry, with its brewed cup, was dropped. The recording's last step shows Cups brewed: 0 on Mint tea.
How many prefetch requests may be in flight before a task that isn't the hovered link's has to wait?
if (task.priority === PrefetchPriority.Intent) {
// …
return inProgressRequests < 12
}
// The default limit is lower than the limit for a hovered link.
return inProgressRequests < 44. hasNetworkBandwidth returns inProgressRequests < 4 for a task that isn't the hovered link's, so a fifth prefetch waits until one closes. The hovered link's task may go up to 12.
After these steps on the tea pages, what has keyboard focus?
click Mint tea
click Brew a cup
click Green tea
click Lemon tea
click Chai
Back
Back
BackThe Chai link, in the recording. Next.js doesn't move focus, and these links are in the layout, which stays on screen, so the browser never had a reason to reset it: the last link clicked kept focus through the three Back steps, though the page shows Mint tea.
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.
Question 1 of 7What does a plain click on a
Linkto another page of the same app do first?Choose an answer to see the explanation.
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.
Question 3 of 7When does the client router give up on a soft navigation?
Choose an answer to see the explanation.
Question 4 of 7What goes into the route cache's key in Next.js 16.4.3?
Choose an answer to see the explanation.
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.
Question 6 of 7With Cache Components on, how many entries does one level of the route keep?
Choose an answer to see the explanation.
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.pushis on another site, or a redirect's meta-refresh tag is on the page: thenmpaNavigationmakesAppRoutercalllocation.assign. ALinkto 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
IntersectionObserverwith 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
Activityboundaries. 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.