Linking, Navigation and Prefetching
What happens between a click on a Next.js Link and the new page on screen, how the segment cache prefetches and reuses route data, and when to use useRouter, useLinkStatus, scroll control or redirect in Next.js 16.4.3.
- Published
- Reading time
- 13 min read
<Link> renders a plain <a>, registers it with one shared IntersectionObserver, and in production prefetches the route when it comes within 200px of the viewport (and with higher priority on hover or touch). Prefetched data goes into the segment cache, keyed by pathname, search and the Next-Url header, and stored per segment so shared layouts are downloaded once. A click calls navigate() inside startTransition: if the route is in the cache the navigation is synchronous and any missing dynamic data streams in behind Suspense; if not, a single request fetches the whole route. useLinkStatus reads an optimistic pending flag the router sets on the clicked link, scrolling is done by a Fragment ref on the first changed segment, and redirect() is a thrown error that the nearest RedirectBoundary turns into router.replace or router.push.
In part 01 the server only rendered segments that changed, and in part 02 a navigation fetched an RSC Payload instead of HTML. This part follows that request from the browser's side.
A click, start to finishCopy link to section:
<Link> is a Client Component that renders an <a href>, so a page works before hydration and middle-click still opens a new tab. Its onClick calls your onClick first, stops if you called preventDefault(), then hands off to linkClicked.1
linkClicked decides whether this is a client navigation at all:2
- Modified clicks (
targetother than_self, Cmd/Ctrl/Shift/Alt, middle button) and links withdownloadreturn early, so the browser does its normal thing.3 - External URLs return early too (with a manual
location.replaceif you asked forreplace). - Otherwise it calls
e.preventDefault(), then youronNavigate. IfonNavigatecalls its ownpreventDefault(), the navigation is cancelled. That is the hook for "you have unsaved changes". - Finally it calls
navigate(href, 'push' | 'replace', scrollBehavior, linkInstance, transitionTypes, prefetchIntent).
navigate lives behind a small module, navigator.ts, whose comment explains it is the seam between the stable router queue and an experimental concurrent one (experimental.concurrentRouterQueue).4 The default implementation does the important work in one startTransition:5
// sequential-router-queue.ts (simplified)
export function navigate(href, navigateType, scrollBehavior, linkInstance, transitionTypes, prefetchIntent) {
if (isJavaScriptURLString(href)) throw new Error('Next.js has blocked a javascript: URL ...')
startTransition(() => {
for (const type of transitionTypes ?? []) addTransitionType(type)
const url = new URL(addBasePath(href), location.href)
setLinkForCurrentNavigation(linkInstance) // drives useLinkStatus
dispatchAppRouterAction({ type: ACTION_NAVIGATE, url, isExternalUrl: isExternalURL(url),
scrollBehavior, navigateType })
})
}Because the update is a transition, React keeps showing the current page while the new one is prepared, and an in-flight navigation can be superseded by a newer one (see How React Works: transitions). transitionTypes (the <Link transitionTypes> prop added in 16.2) are passed to addTransitionType, which is what <ViewTransition> reads to choose an animation.
The reducer then asks the segment cache for help:67
Diagram source:
sequenceDiagram
participant U as User
participant L as Link
participant R as Router (transition)
participant C as Segment cache
participant S as Server
U->>L: click
L->>R: navigate(href)
R->>C: readRouteCacheEntry(pathname, search, nextUrl)
alt prefetched route tree
C-->>R: route tree + cached segments
R-->>U: commit new tree now (cached parts render)
R->>S: request only missing dynamic segments
S-->>R: stream into Suspense boundaries
else nothing cached
R->>S: one dynamic request for the whole route
S-->>R: tree + data in a single stream
R-->>U: commit when the shell arrives
endThe comment on navigate states the design goal: "To allow for synchronous navigations whenever possible, this is not an async function. It returns a promise only if there's no matching prefetch in the cache."7 On a miss, navigateToUnknownRoute does not wait for a prefetch first; it fetches the static and dynamic parts in one stream and then pretends the response was a prefetch so the rest of the pipeline is shared.8
Prefetching: what, when and how muchCopy link to section:
When a link prefetchesCopy link to section:
Every <Link> attaches a callback ref that registers the anchor with mountLinkInstance.9 All links share one IntersectionObserver with rootMargin: '200px', so a link starts prefetching slightly before it scrolls into view.10
- Production only.
onLinkVisibilityChangedreturns immediately in development, because prefetching would force the dev server to compile every linked page.11 Do not judge prefetching fromnext dev. - Leaving the viewport cancels.
rescheduleLinkPrefetchcancels the task of a link that is no longer visible.12 - Hover and touch upgrade priority.
onMouseEnterandonTouchStartcallonNavigationIntent, which reschedules the link withPrefetchPriority.Intent.13 - Top links first. Entries are processed in reverse document order because the scheduler favours the most recently scheduled task, so the link nearest the top wins.14
The scheduler caps concurrency: at most 4 prefetch requests in flight normally, and up to 12 when the newest task is a hover.15 It also pauses during a short cooldown after a revalidation, so a CDN has time to see the new data. After any cache invalidation, pingVisibleLinks re-prefetches every visible link whose task is now stale.16
There is only ever one Intent task. The priorities are Intent = 2, Default = 1 and Background = 0.17
How much a link prefetchesCopy link to section:
The prefetch prop picks a prefetch intent, which maps to a fetch strategy:1819
The table is the practical summary of getFetchStrategyFromPrefetchIntent: under Cache Components auto becomes the PPR strategy and true becomes Full; without it, auto starts as PPR and is downgraded to the loading-boundary strategy once the first prefetch reveals that the route does not support it.18
That is why the docs keep telling you to add loading.tsx to dynamic routes. Without Cache Components, a dynamic route with no loading boundary anywhere is not prefetched beyond its route tree: the server's tree walk sends "only the router state" for a prefetch when there is no loading component in the subtree.20 With a loading.tsx, the layouts and the skeleton are prefetched, the click commits instantly, and the page streams in.
partialPrefetching: true in next.config (it requires Cache Components) changes the default again: auto prefetches one App Shell per route, shared by every link to that route, instead of a per-URL page. The FetchStrategy enum carries the extra tiers (StaticShell, RuntimeShell, PPRRuntime) for this mode.19 Parts 23 and 44 go deeper.
The client cacheCopy link to section:
Prefetched and navigated data lives in the segment cache, a global in-memory structure that the router reads and writes outside React state. Two details shape its behaviour.
Route entries are keyed by pathname, search and nextUrl.21 nextUrl is the Next-Url header value, so the intercepted and non-intercepted versions of the same URL (part 01) never collide. Different search params are different route entries. If the client and server disagree on the deployment ID, the navigation becomes a hard one (deployment IDs).
Segment data is stored per segment. The 16 upgrade guide calls this layout deduplication and incremental prefetching: when twenty product links share (shop)/layout.tsx, the layout is fetched once, and a prefetch only requests segments not already cached. The trade-off is more, smaller prefetch requests in the network tab. On a static export the same requests go to .txt files, see client navigation without a server.
How long entries stay fresh is controlled by staleTimes, whose defaults are dynamic: 0 and static: 300 seconds.22 The config comment explains which applies: dynamic when prefetch is left unspecified, static when it is true, and static must be at least 30 seconds.23 With the default dynamic: 0, dynamic data is never reused from the client cache on a later navigation; you always get fresh server output for dynamic segments.
A prefetch is a real server render of layouts and (for static routes) pages. A trackPageView() call in a Server Component fires when the link scrolls into view, not when the user arrives. Put side effects in an event handler, a useEffect, or a Server Action.
Rendering the new routeCopy link to section:
When the new tree commits, each LayoutRouter whose child changed renders InnerLayoutRouter for the new segment. If the segment has prefetched data (prefetchRsc) and full data (rsc) that may still be pending, it uses React's useDeferredValue(rsc, prefetchRsc): the first render shows the prefetched version (often the loading.tsx fallback or a PPR shell), then React re-renders with the full data when it arrives.24 If the data is a promise, use() suspends into the nearest Suspense boundary. If the server returned nothing for the segment, it suspends forever and lets the router recover.25
So the user sees one of three things after a click:
- Cached and complete: the new page, immediately.
- Cached shell, dynamic holes (PPR navigations): the new layout and skeleton immediately, content streaming in.
- Nothing cached: the old page stays (it is a transition) until the server sends the new shell. This is the "my link feels dead" case.
useLinkStatus: feedback for case 3Copy link to section:
useLinkStatus returns { pending } for the nearest parent <Link>. Under the hood, every Link holds useOptimistic(IDLE_LINK_STATUS), and setLinkForCurrentNavigation flips the previous link to idle and the clicked one to pending inside a transition.2627 Because it is optimistic state, it resets automatically when the navigation transition finishes. Only the most recently clicked link is pending.
// app/ui/nav-link.tsx
'use client'
import Link, { useLinkStatus } from 'next/link'
function PendingDot() {
const { pending } = useLinkStatus()
return <span aria-hidden className={pending ? 'dot dot--pending' : 'dot'} />
}
export function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<Link href={href}>
{children}
<PendingDot />
</Link>
)
}/* show the hint only if the navigation takes longer than ~100ms */
.dot { opacity: 0; }
.dot--pending { animation: appear 150ms 100ms forwards; }
@keyframes appear { to { opacity: 1; } }The hook must be called in a descendant of the Link (it reads a context the Link provides).27 If the route was prefetched, the pending phase is usually too short to see. Treat useLinkStatus as a patch for slow links, and fix the cause with loading.tsx or prefetching.
useRouter() from next/navigation (never next/router in app/) returns the shared router instance:28
push and replace are the same navigate() call as a Link click, without a link instance (so no useLinkStatus) and without a prefetch intent.29 refresh() dispatches ACTION_REFRESH in a transition.30 It clears the client cache for the current route but does not touch server caches; to invalidate cached data on the server use revalidateTag, updateTag or revalidatePath (part 06).
// app/products/sort-select.tsx
'use client'
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
export function SortSelect() {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
return (
<select
defaultValue={searchParams.get('sort') ?? 'popular'}
onChange={(e) => {
const params = new URLSearchParams(searchParams)
params.set('sort', e.target.value)
router.replace(`${pathname}?${params}`, { scroll: false })
}}
>
<option value="popular">Popular</option>
<option value="price">Price</option>
</select>
)
}A Link gets viewport and hover prefetching, a real href for crawlers and middle-click, and useLinkStatus. Use router.push for navigations that do not come from a link: after a form succeeds, on a keyboard shortcut, in a command palette. If you build your own link from router, you own prefetching (router.prefetch with onInvalidate) as well.
For URL state that should not trigger a server round trip at all, window.history.pushState and replaceState are integrated with the router: usePathname and useSearchParams follow them.
useSearchParams and prerenderingCopy link to section:
useSearchParams is a client hook, but it has a server-side consequence. During a prerender the search params are unknown, so the hook calls useDynamicSearchParams, which, depending on the render mode, suspends on a promise that never resolves during the prerender or throws a bailout error.3132 Either way, the Client Component tree up to the nearest Suspense boundary is rendered in the browser instead of the build. Wrap components that read search params in <Suspense> so the rest of the page can still be prerendered.
Scrolling and focusCopy link to section:
After a navigation, InnerScrollHandler runs in a layout effect on the segments that changed. It uses a React Fragment ref around the segment's children, so it does not need a wrapper <div>:33
- With a
#hash, it scrolls the element with that id (or the first element with thatname, or the top for#top) into view. - Otherwise it measures the segment. If its top edge is already in the viewport (below any
scroll-padding-top), it does nothing. - If not, it sets
document.documentElement.scrollTop = 0, and if the segment is still not visible, callsscrollIntoView()on it. - It marks the scroll as handled, so only one segment scrolls per navigation.
So "Next scrolls to the top" is really "Next makes the new segment visible". Navigating between two tabs inside a tall layout leaves the scroll where it is if the tab content is on screen. For a sticky header, set scroll-padding-top on html; the handler reads it.33
Pass scroll={false} to Link (or { scroll: false } to push/replace) to skip all of this.
Up to 15, Next temporarily set scroll-behavior: auto during navigations if your CSS had scroll-behavior: smooth. In 16 it leaves your setting alone. Add data-scroll-behavior="smooth" to <html> to get the old override back.
The scroll handler explicitly leaves focus untouched ("resetting focus on navigation is deferred").33 The App Router does include a route announcer for screen readers, but if your layout needs focus to move to the new <h1> or <main>, do it yourself in the page, for example with a small client component that focuses a heading in useEffect.
redirect() versus router.push()Copy link to section:
redirect() and permanentRedirect() from next/navigation do not navigate. They throw an error whose digest encodes the target: NEXT_REDIRECT;<type>;<url>;<status>;.34 Whoever catches it decides what happens:
- Server Component render: the server converts it to a redirect response on the initial request (or a meta refresh if streaming has already started).
- Route Handler: a 307 (
redirect) or 308 (permanentRedirect). - Server Action: a client-side navigation when JavaScript has loaded, a 303 for a no-JS form post. The
typedefaults topushinside a Server Action and toreplaceeverywhere else.34 - Client Component render: the error reaches the
RedirectBoundarythat every segment has (part 01).HandleRedirectthen callsrouter.pushorrouter.replacein a transition.35
Because it throws, redirect() must not be called inside a try block that swallows errors, and it cannot be used in event handlers (there is no boundary to catch it). The rule of thumb:
Status codes, notFound() and the other interrupts are covered in part 10. The try/catch pitfall has a recipe: redirect() inside try/catch.
SummaryCopy link to section:
A Next.js navigation is a React transition backed by a cache. <Link> prefetches through a single IntersectionObserver and a small priority scheduler, only in production, and the prefetch prop chooses how much: a shell, everything down to loading.tsx, or the full route. The segment cache stores data per segment under a key of pathname, search and Next-Url, so shared layouts download once and intercepted routes stay separate. On click, a cached route commits at once and streams the rest; an uncached route fetches everything in one request while the old page stays visible. useLinkStatus, useRouter, the scroll handler and redirect() are all thin layers on that flow. Next, part 04 looks at the server side of the same request: fetching data in async components and streaming it through Suspense.
Key takeawaysCopy link to section:
linkClicked: modified clicks, downloads and external URLs fall through to the browser;onNavigatecan cancel a client navigation.navigate(): runs instartTransition, appliestransitionTypes, marks the link pending and dispatchesACTION_NAVIGATE.- Cache hit or miss: a prefetched route navigates synchronously and streams missing data; a miss sends one request for the whole route.
- Prefetch triggers: one observer with a 200px margin, production only, hover and touch get
Intentpriority, at most 4 (or 12) requests in flight. - Prefetch amount:
automeans shell or up-to-loading.tsx,truemeans full,falsemeans never; dynamic routes withoutloading.tsxbarely prefetch without Cache Components. - Segment cache: keyed by pathname, search and
Next-Url, stored per segment, withstaleTimesdefaults of 0 (dynamic) and 300 seconds (static). useLinkStatus: optimistic per-link state, only in descendants of theLink, mostly useful when prefetching did not finish.- Scroll: only scrolls if the new segment is off screen;
scroll={false}opts out, and 16 no longer overrides smooth scrolling. redirect(): a thrown error with a digest, handled differently in Server Components, Route Handlers, Server Actions and the clientRedirectBoundary.
← Server and Client Components · Index · Data Fetching and Streaming →
Footnotes
-
<Link>onClickandonMouseEnterhandlers — packages/next/src/client/app-dir/link.tsx#L666-L729 (opens in a new tab) ↩ -
linkClicked— packages/next/src/client/app-dir/link.tsx#L253-L318 (opens in a new tab) ↩ -
isModifiedEvent— packages/next/src/client/app-dir/link.tsx#L240-L251 (opens in a new tab) ↩ -
navigateReducer— packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts#L23-L56 (opens in a new tab) ↩ -
navigateToUnknownRoute— packages/next/src/client/components/app-router-state.ts#L447-L472 (opens in a new tab) ↩ -
observeLinkVisibilityOnMountcallback ref — packages/next/src/client/app-dir/link.tsx#L620-L653 (opens in a new tab) ↩ -
shared
IntersectionObserverwithrootMargin: '200px'— packages/next/src/client/components/links.ts#L121-L127 (opens in a new tab) ↩ -
onLinkVisibilityChanged(disabled in development) — packages/next/src/client/components/links.ts#L271-L291 (opens in a new tab) ↩ -
rescheduleLinkPrefetch— packages/next/src/client/components/links.ts#L314-L366 (opens in a new tab) ↩ -
onNavigationIntent— packages/next/src/client/components/links.ts#L293-L312 (opens in a new tab) ↩ -
handleIntersectprocesses entries in reverse — packages/next/src/client/components/links.ts#L252-L269 (opens in a new tab) ↩ -
hasNetworkBandwidth(4 default, 12 for intent, revalidation cooldown) — packages/next/src/client/components/segment-cache/scheduler.ts#L487-L526 (opens in a new tab) ↩ -
pingVisibleLinks— packages/next/src/client/components/links.ts#L368-L401 (opens in a new tab) ↩ -
PrefetchPriority— packages/next/src/client/components/segment-cache/types.ts#L15-L32 (opens in a new tab) ↩ -
prefetch intent from the
prefetchprop andgetFetchStrategyFromPrefetchIntent— packages/next/src/client/app-dir/link.tsx#L381-L389 (opens in a new tab) and #L808-L827 (opens in a new tab) ↩ ↩2 -
FetchStrategy— packages/next/src/client/components/segment-cache/types.ts#L34-L66 (opens in a new tab) ↩ ↩2 -
prefetch without a loading boundary sends only router state — packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx#L176-L193 (opens in a new tab) ↩
-
createCacheKey— packages/next/src/client/components/segment-cache/cache-key.ts#L20-L31 (opens in a new tab) ↩ -
staleTimesdefaults — packages/next/src/server/config-shared.ts#L2467-L2470 (opens in a new tab) ↩ -
staleTimesconfig documentation — packages/next/src/server/config-shared.ts#L603-L613 (opens in a new tab) ↩ -
useDeferredValue(rsc, prefetchRsc)— packages/next/src/client/components/layout-router.tsx#L333-L341 (opens in a new tab) ↩ -
InnerLayoutRouter— packages/next/src/client/components/layout-router.tsx#L302-L414 (opens in a new tab) ↩ -
PENDING_LINK_STATUS,IDLE_LINK_STATUS,setLinkForCurrentNavigation— packages/next/src/client/components/links.ts#L73-L88 (opens in a new tab) ↩ -
LinkStatusContextanduseLinkStatus— packages/next/src/client/app-dir/link.tsx#L800-L806 (opens in a new tab) ↩ ↩2 -
publicAppRouterInstance— packages/next/src/client/components/app-router-instance.ts#L324-L335 (opens in a new tab) ↩ -
pushandreplace— packages/next/src/client/components/sequential-router-queue.ts#L95-L119 (opens in a new tab) ↩ -
refresh— packages/next/src/client/components/sequential-router-queue.ts#L155-L161 (opens in a new tab) ↩ -
useSearchParams— packages/next/src/client/components/navigation.ts#L55-L92 (opens in a new tab) ↩ -
useDynamicSearchParams— packages/next/src/server/app-render/dynamic-rendering-client.ts#L80-L139 (opens in a new tab) ↩ -
InnerScrollHandler— packages/next/src/client/components/layout-router.tsx#L148-L278 (opens in a new tab) ↩ ↩2 ↩3 -
getRedirectError,redirect,permanentRedirect— packages/next/src/client/components/redirect.ts#L10-L61 (opens in a new tab) ↩ ↩2 -
HandleRedirectin the redirect boundary — packages/next/src/client/components/redirect-boundary.tsx#L13-L36 (opens in a new tab) ↩