Measured against Next.js 16.1.7 on 2026-09-10, on the deployed site (@opennextjs/cloudflare on Cloudflare Workers). The useRouter shapes are read from the installed next package at the same version.
The docs tell you useRouter moved from next/router to next/navigation, and that <Link> "prefetches routes as they enter the viewport."12 They do not tell you how many bytes that prefetch is, and they do not give you one list of the next/router API that has no replacement - it is scattered across a migration guide, an API reference, and a hooks page.
So we measured both. Seven routes on this blog, fetched three ways with curl: the full HTML document, the payload a soft navigation pulls (RSC: 1), and the payload a <Link> prefetch pulls (RSC: 1 plus Next-Router-Prefetch: 1). Then the useRouter return type, read straight out of node_modules/next at 16.1.7.
A soft navigation transfers about half the bytes of a full page load - real, but modest. A <Link> prefetch transfers the same payload as the navigation it is preparing: on a statically prerendered route with no loading.tsx, that is the entire route, and every <Link> in the viewport pulls one, in the background, in production only. useRouter() from next/navigation has six methods and nothing else. router.events, router.query, router.pathname, router.asPath, router.isReady, router.isFallback, router.beforePopState and shallow routing are all gone - some with a documented replacement, three with none.
The setup
The site under test is nowaterprogramming.com: Next.js 16.1.7, App Router, built with @opennextjs/cloudflare and served from Cloudflare Workers. Every route is statically prerendered - each dynamic segment has generateStaticParams and dynamicParams = false, and no route sets dynamic or revalidate. There is no loading.tsx anywhere in app/. That last fact turns out to decide the prefetch result.
Each measurement is one curl -H 'Accept-Encoding: gzip', three times per route:
- document - a plain
GET. What a hard navigation or a first visit downloads. - soft-nav RSC -
GETwithRSC: 1. What<Link>fetches on click, and whatrouter.push/router.replacefetch. - prefetch RSC -
GETwithRSC: 1andNext-Router-Prefetch: 1. What a<Link>fetches when it scrolls into view.
wire below is the gzipped size of the response body. The useRouter interfaces come from dist/shared/lib/app-router-context.shared-runtime.d.ts (App Router) and dist/shared/lib/router/router.d.ts (Pages Router) in the installed package.
Soft navigation is about half a page load
| route | document (wire) | soft-nav RSC (wire) | soft-nav / document |
|---|---|---|---|
/ | 13,836 | 6,834 | 49% |
/blog/nextjs-api-routes | 40,652 | 21,565 | 53% |
/blog/getting-started-with-nextjs | 32,405 | 16,950 | 52% |
/blog/qr-codes-101 | 19,146 | 10,358 | 54% |
/authors/nowaterprogramming-team | 17,004 | 8,874 | 52% |
/topics/nextjs | 6,846 | 4,573 | 67% |
/topics | 6,360 | 4,135 | 65% |
A soft navigation to a content page moves a little over half the bytes of loading that page fresh. Uncompressed the gap is wider - the largest article is 348 KB as a document and 178 KB as an RSC payload - because the document also carries the <head>, the framework bootstrap <script>, and the same React tree twice: once as rendered HTML and once inlined as flight data for hydration. The RSC payload is just the flight data.
The saving shrinks as the page shrinks. The two /topics list pages are almost all shell - header, footer, dot background - so the fixed cost dominates and the RSC payload is two thirds of the document rather than half.
Half a document is the ceiling on what a soft navigation buys you in transfer. It is not the 10x the phrase "instant navigation" suggests. What it also buys does not show up in a byte count: the shared layout is not re-rendered, useState and scroll position survive, there is no white flash, and the root layout's server components do not re-run.2 That is the actual reason to use <Link> over an <a>, and it is worth more than the bytes.
A <Link> prefetch is the whole route
| route | soft-nav RSC (wire) | prefetch RSC (wire) |
|---|---|---|
/ | 6,834 | 6,834 |
/blog/nextjs-api-routes | 21,565 | 21,534 |
/blog/getting-started-with-nextjs | 16,950 | 16,950 |
/blog/qr-codes-101 | 10,358 | 10,358 |
/authors/nowaterprogramming-team | 8,874 | 8,874 |
/topics/nextjs | 4,573 | 4,573 |
/topics | 4,135 | 4,135 |
The prefetch payload and the soft-navigation payload are the same bytes. On the one route where they differed at all it was 31 bytes out of 21,500 - run-to-run noise.
This is documented behaviour, stated plainly once you find it: "a static route is prefetched in full, while a dynamic route is skipped unless it has a loading.js boundary."3 Every route on this site is a static prerender, and none has a loading.tsx, so there is no partial mode available. Each prefetch is the full route.
The consequence is visible on the homepage. It renders one <Link> per article - fifteen of them. In a production build, each one that enters the viewport fires a background request for that route's entire RSC payload: between 4 KB and 21 KB gzipped, scheduled through a small queue that prioritises links in the viewport, then links under hover or touch, discarding links that scroll back off-screen.3 Land on the homepage, scroll once, and you have pulled most of the site.
We have been bitten by exactly this. Earlier, the article template rendered every tag as a link to /topics/<slug>, including tags whose topic page was never created. The App Router prefetched every one of those links, each 404'd, and the console filled with errors on every article page - which cost the site its Lighthouse best-practices score. The fix was to stop rendering the links, because there is no way to suppress the prefetch of one <Link> except prefetch={false}, and a link you have decided not to prefetch is usually a link you should not render.
To actually control it:
prefetch={false}on a<Link>turns off both the viewport and the hover prefetch. Static routes are then fetched on click; dynamic routes wait for a server render before the transition completes.2 This is a client decision - no request is made - so it shows up in the browser's network panel as the absence of aNext-Router-Prefetchrequest, not in any server log.- Hover-only, the pattern the docs give:
prefetch={active ? null : false}withonMouseEnter={() => setActive(true)}.nullrestores the default once the user shows intent.2 - A
loading.tsxboundary switches a dynamic route from "skipped" to "partial prefetch" - the layout down to the first loading boundary. It does nothing for a fully static route except add a fallback flash the route never needed. - Partial Prefetching (
partialPrefetchingconfig plus Cache Components) replaces the all-or-nothing model with one shared App Shell per route, fetched once however many links point at it.3 It is opt-in and off by default.
The response is the same object, three ways
The document, the soft-nav payload and the prefetch payload came back with identical headers except one. For /blog/nextjs-api-routes:
content-type: text/html; charset=utf-8 (document)
content-type: text/x-component (RSC: 1, with or without Next-Router-Prefetch: 1)
cache-control: s-maxage=31536000 (all three)
x-nextjs-cache: HIT (all three)
x-nextjs-prerender: 1,1 (all three)
x-nextjs-stale-time: 300 (all three)
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
Three things worth reading off that:
x-nextjs-stale-time: 300is the client Router Cache TTL, in seconds. A prefetched static entry is held in memory and reused for five minutes before it is refetched - that is thestaleTimes.staticdefault;staleTimes.dynamicis0.3 A second navigation to a route you visited under five minutes ago makes no request at all.Next-Router-Prefetch: 1changed nothing in the response. It is one of thevarykeys, and the cached object was already the full prerender. The header tells the router how to file the response, not the server what to build.varylists four request headers the RSC payload is cached against, which is how the CDN holds thetext/htmlandtext/x-componentversions of one URL side by side.
The flight payload itself is text/x-component - a line-based serialisation of the React tree:
1:"$Sreact.fragment"
2:I[63178,["/_next/static/chunks/7b42493791e94873.js", ...],"ThemeProvider"]
3:I[79520,[...],""]
...
Each I[id,[chunks],"export"] row is a client-component reference; the serialised element tree follows. This is what router.push merges into the page without touching the DOM that did not change.
router.refresh() fetches this same payload for the current route, bypassing the client cache, and re-runs every server component and its data fetching. It merges the result "without losing unaffected client-side React (e.g. useState) or browser state (e.g. scroll position)," and it clears the client cache for that route but not the server-side data cache.4 It is the soft-navigation equivalent of a reload: about half a document on the wire, a full re-render on the server.
push, <Link>, redirect - which one
<Link href>is the default and the recommendation.4 It renders a real<a>, prefetches, does a soft navigation on click, and pushes a history entry. Use it for anything a user clicks. Do not rebuild it as an<a onClick={() => router.push(...)}>- you lose the prefetch and the middle-click / cmd-click behaviour of an anchor.router.push(href, { scroll })androuter.replace(href, { scroll })are the programmatic form: same transport as a<Link>click, no prefetch unless you also calledrouter.prefetch(href). Reach for them after an action - a form submit, a selection - not as a click handler on something that could be a link.replaceomits the history entry. In 16.1.7 the only option isscroll; there is noshallowand nolocale.redirect(path)andpermanentRedirect(path)fromnext/navigationare server-side. They throwNEXT_REDIRECT, produce a307(or308for the permanent form), and must be called outside atry/catchor the redirect is swallowed.5 Not a client API.router.refresh()keeps the URL, re-pulls server data, keeps client state.
Only <Link> and router.prefetch() warm the client cache. router.push to a route nothing prefetched pays the full server round trip.
Every next/router member, and where it went
The Pages Router's NextRouter had around twenty members. useRouter() from next/navigation returns an AppRouterInstance with six methods and no properties at all:
back(): void
forward(): void
refresh(): void
push(href: string, options?: { scroll?: boolean }): void
replace(href: string, options?: { scroll?: boolean }): void
prefetch(href: string, options?: { kind?: 'auto' | 'full'; onInvalidate?: () => void }): voidtsEverything that used to be a property is now a separate hook, or is gone.
next/router (Pages) | App Router, 16.1.7 |
|---|---|
router.pathname | usePathname()1 |
router.query | useSearchParams() for the query string, useParams() for dynamic segments - two objects, not one1 |
router.asPath | reassemble it: `${usePathname()}?${useSearchParams()}`. "The concept of as has been removed from the new router."1 |
router.route | no direct value; usePathname() or useSelectedLayoutSegments() is the nearest1 |
router.isReady | gone. useSearchParams() returns real values on the client's first render; during prerender it opts the route into client rendering up to the nearest Suspense boundary, which is the replacement for waiting on the router16 |
router.isFallback | gone - fallback was replaced by dynamicParams1 |
router.events (routeChangeStart, routeChangeComplete, hashChangeStart, ...) | no direct replacement. Compose usePathname() + useSearchParams() in a useEffect, wrapped in <Suspense>; or useLinkStatus() for per-link pending state. Neither fires as early as routeChangeStart did47 |
router.beforePopState | no replacement. There is no App Router hook for intercepting the back button |
router.reload() | router.refresh() (soft, re-runs server components) or window.location.reload() (hard) |
router.push(url, as, { shallow: true }) | gone from the router. Call window.history.pushState / replaceState directly - Next patches both to stay in sync with usePathname / useSearchParams. It updates the URL without re-running server data fetching, which is the point, and also means a Server Component reading searchParams will not see the change until a real navigation8 |
router.push(url, as, { locale }), router.locale, router.locales | gone - "built-in i18n Next.js features are no longer necessary in the app directory." Middleware plus a [lang] segment now1 |
router.basePath | removed from useRouter; "the alternative will not be part of useRouter. It has not yet been implemented."1 |
router.isPreview | draftMode(), server-side |
shared pages + app components | useRouter() from next/compat/router - returns the Pages router, or null under the App Router1 |
Importing useRouter from next/router inside an app/ component does not fall back to anything - it throws NextRouter was not mounted at runtime.9
New in the App Router with no Pages equivalent: redirect() / permanentRedirect(), notFound() / forbidden() / unauthorized(), <Link> viewport prefetch, useLinkStatus(), and useSelectedLayoutSegment(s)().
What this site does
The site measured here is nowaterprogramming.com, which is ours; every number above and the package inspection are our own primary work.
It uses <Link> everywhere and next/router nowhere. There is no route-change progress bar - the site is static, soft navigations resolve from the client cache in single-digit milliseconds, and there is nothing to indicate. Tag labels render as plain text, not links, wherever the topic page does not exist, because the prefetch of a dead /topics/<slug> was the thing generating console 404s.
There is no loading.tsx on any route. Every page is a full static prerender, so a loading boundary would only introduce a fallback flash the site never needs. The cost is that prefetch has no partial mode and pulls each route whole - accepted, because the payloads are 4-21 KB gzipped and everything is behind a CDN with a one-year s-maxage.
What to standardise on
<Link>for anything clickable;router.pushonly after an action. An<a>with anonClickthat callsrouter.pushloses prefetch and native anchor behaviour.- Budget prefetch on long lists. A screen of thirty
<Link>s is thirty background RSC fetches. Useprefetch={false}below the fold, or the hover-only wrapper. - Add
loading.tsxonly where a route is genuinely dynamic and slow. On a static route it buys a fallback flash and nothing else. - Reassemble
asPathyourself -`${pathname}${qs ? `?${qs}` : ''}`. No single property returns it. - Wrap any component that calls
useSearchParams()in<Suspense>, or the whole route opts into client rendering during prerender.6 - For query-string-only updates, use
window.history.replaceStateand accept that Server Components will not re-readsearchParamsuntil a real navigation.8 - Call
redirect()outsidetry/catch. It throws a sentinel; a catch swallows the redirect.5
Where this is weak
- One version, one host. 16.1.7, App Router,
@opennextjs/cloudflareon Workers. The prefetch model changed across 13 to 15 (partial prefetch,staleTimes), andpartialPrefetchingwith Cache Components changes it again. This is the default behaviour with neither enabled. - An all-static site. With dynamic routes and
loading.tsxboundaries, the prefetch payload would be smaller than the soft-nav payload - we could measure neither here because we have neither. The numbers are a lower bound on prefetch traffic, not an upper one. - A CDN in front. Every response was an edge-cache hit (
x-nextjs-cache: HIT), 0.27-0.48 s to first byte for all three modes. That says nothing about origin render cost, so we compared bytes and did not compare navigation timing. curl, not a browser. The byte counts are transport facts and client-independent. The prefetch scheduling - what actually enters the viewport, hover behaviour, the queue - is taken from the docs, not from instrumenting a real scroll.- RSC payload only. A prefetch also pulls the route's client-component JS chunks. Those are content-hashed and shared across routes, so they cache well and do not vary per navigation; we measured the part that does.
FAQ
Where do I import useRouter from in the Next.js App Router?
next/navigation, not next/router. next/router is the Pages Router hook; importing it in an app/ component throws NextRouter was not mounted at runtime.9 The next/navigation useRouter only works in a Client Component ('use client').4
What replaced router.query in the App Router?
Two hooks. useSearchParams() returns the query string as a read-only URLSearchParams; useParams() returns the dynamic route segments. There is no combined object, and there is no router.pathname either - that is usePathname().1
What replaced router.events and routeChangeStart?
Nothing directly. The documented pattern is a useEffect that depends on usePathname() and useSearchParams(), placed in a component wrapped in <Suspense>.4 For per-link feedback there is useLinkStatus(), which returns { pending } while a navigation triggered by that link is in flight.7 Neither fires before the navigation starts, the way routeChangeStart did.
How do I do shallow routing in the App Router?
Call window.history.pushState(null, '', url) or replaceState directly. Next.js integrates both with its router so usePathname and useSearchParams stay in sync.8 The { shallow: true } option on router.push no longer exists. Note that this updates the URL without re-running server-side data fetching - intended, but it means a Server Component reading searchParams will not react until a full navigation.
How much does a <Link> prefetch download?
On a statically prerendered route with no loading.js, the route's entire RSC payload - measured here at 4 KB to 21 KB gzipped per route. Every <Link> in the viewport pulls one, and it happens in production builds only; next dev does not prefetch.3
Does prefetching happen in next dev?
No. Automatic <Link> prefetching runs only in production.3 A local dev build fetches each route on click.
Is router.push faster than <Link>?
Same transport once it runs. <Link> is usually faster in practice because it has already prefetched the route by the time you click; router.push to a route nothing prefetched pays the full server round trip. If you need router.push for a route you know is coming, call router.prefetch(href) first.4
How do I stop Next.js prefetching every link on a page?
Per link: prefetch={false}, or a wrapper that only sets prefetch to its default on onMouseEnter. There is no global switch. prefetch={false} means static routes are fetched on click and dynamic routes render on the server before the transition completes.2
What status code does redirect() return?
307 by default, 308 from permanentRedirect(). It works by throwing NEXT_REDIRECT, so it must be called outside any try/catch.5
Disclosure
The site measured throughout this article is nowaterprogramming.com, which is ours. Every payload measurement and the inspection of the installed next package are our own primary work; nothing published on the site is used as a source.
Sources
Checked 2026-09-10, against Next.js 16.1.7.
Sources
-
Next.js docs: How to migrate from Pages to the App Router - "Step 5: Migrating Routing Hooks": that
useRouteris imported fromnext/navigationinappand behaves differently from thenext/routerhook; that the newuseRouterdoes not returnpathname(useusePathname) orquery(useuseSearchParamsanduseParams); and thatisFallback,locale/locales/defaultLocales/domainLocales,basePath("the alternative will not be part ofuseRouter. It has not yet been implemented."),asPath("the concept ofashas been removed"),isReady, androutehave all been removed, plus thenext/compat/routerexport for shared components. -
Next.js docs: Linking and Navigating - that a full page load "clears state, resets scroll position, and blocks interactivity" while a client-side transition with
<Link>keeps shared layouts and UI; that for a static route "the full route is prefetched"; thatprefetch={false}makes static routes fetch on click and dynamic routes render on the server first; and the hover-onlyprefetch={active ? null : false}pattern. -
Next.js docs: Prefetching - that "a static route is prefetched in full, while a dynamic route is skipped unless it has a
loading.jsboundary"; that the client cache TTL for a prefetched static route is "5 min (default)" viastaleTimes.staticand dynamic is "Off" by default; that "Automatic prefetching runs only in production"; the prefetch scheduling queue order (viewport, then hover/touch, newer replaces older, off-screen discarded); and that Partial Prefetching (partialPrefetching+ Cache Components) switches to one shared App Shell per route. -
Next.js docs: useRouter - that
useRouter"allows you to programmatically change routes inside Client Components", the recommendation to use<Link>unless there is a specific need; the method list (push/replacewith ascrolloption,refresh,prefetchwithonInvalidate,back,forward); thatrefresh()re-requests from the server, re-fetches data and re-renders Server Components while preservinguseStateand scroll position, clearing the client cache for the route but not the server-side cache; and the "Router events" example composingusePathnameanduseSearchParamsin auseEffectinside a<Suspense>boundary. -
Next.js docs: redirect - that
redirect()throws aNEXT_REDIRECTerror, must be called outside atry/catchblock, and serves a307by default (withpermanentRedirect()serving a308). -
Next.js docs: useSearchParams - that
useSearchParams()"causes client-side rendering up to the closestSuspenseboundary during prerendering", so a component using it should be wrapped in<Suspense>or the route opts into client rendering. -
Next.js docs: useLinkStatus - that
useLinkStatus()is imported fromnext/link, returns{ pending: boolean }, and is used to render immediate visual feedback while a<Link>navigation is in progress, particularly on slow networks where the prefetch has not finished. -
Next.js docs: Linking and Navigating - Native History API - that Next.js supports
window.history.pushStateandwindow.history.replaceStateto update the browser history without reloading, and that these calls "integrate into the Next.js Router, allowing you to sync withusePathnameanduseSearchParams", with examples for sorting (pushState) and locale switching (replaceState). -
Next.js docs: next-router-not-mounted - that calling the
next/routeruseRouter(orwithRouter) outside a Pages Router context throwsNextRouter was not mounted, and that App Router code must usenext/navigationinstead.