Navigation Feel — Pending States and Transitions
AdvancedApp Router navigation waits for the server before anything changes on screen. Without a pending indicator the app feels frozen, and the fix is a hook rather than a spinner library.
Overview
This is the UX regression teams hit after moving to the App Router and rarely diagnose correctly. Because a navigation fetches the new segment from the server, a click produces nothing visible until it arrives — no highlighted link, no loading bar, nothing. On a fast connection it is imperceptible; on 4G it feels broken, and users click again. The framework gives you the signals to fix it — `useLinkStatus`, `useTransition`, and `loading.tsx` — but none of them is on by default, so the polish is yours to add.
The Frozen-Click Problem
Why nothing happens, and the smallest fix.
// A click on <Link> triggers a server round trip for the new segment.
// Until it lands, React keeps showing the CURRENT page. No spinner,
// no visual acknowledgement — the app looks unresponsive.
// loading.tsx helps only once the navigation has committed, which is
// after the wait people notice.
// The direct fix (Next 15+): a pending state per link
'use client'
import { useLinkStatus } from 'next/link'
function LinkSpinner() {
const { pending } = useLinkStatus()
return pending ? <Spinner className="h-3 w-3" /> : null
}
<Link href="/problems"><span>Problems</span><LinkSpinner /></Link>
// On earlier versions, drive it yourself with a transition:
'use client'
const [isPending, startTransition] = useTransition()
const router = useRouter()
function go(href: string) {
startTransition(() => router.push(href))
}
<button onClick={() => go('/problems')} aria-busy={isPending}>
Problems {isPending && <Spinner />}
</button>
// The rule from the React track applies unchanged: acknowledge every
// interaction within 100ms, even when the work takes longer.A Global Progress Bar
The pattern most content sites end up with.
// One indicator for every navigation, rather than per link.
'use client'
import { usePathname, useSearchParams } from 'next/navigation'
export function ProgressBar() {
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
NProgress.done() // the route changed — finish
}, [pathname, searchParams])
useEffect(() => {
// start on any internal link click
const onClick = (e: MouseEvent) => {
const a = (e.target as HTMLElement).closest('a')
if (a && a.href.startsWith(location.origin) && !a.target) NProgress.start()
}
document.addEventListener('click', onClick)
return () => document.removeEventListener('click', onClick)
}, [])
return null
}
// Wrap it in Suspense — useSearchParams opts the tree into dynamic
// rendering otherwise:
<Suspense><ProgressBar /></Suspense>
// Keep it subtle. A 2px bar at the top reads as progress; a full
// overlay reads as a page load and undoes the point of the SPA.Prefetching and Perceived Speed
The lever that removes the wait rather than decorating it.
// Link prefetches automatically in PRODUCTION when it enters the
// viewport — which is why navigation feels instant on a deployed site
// and sluggish in dev. Test perceived speed on a build.
<Link href={href} prefetch={true} /> // eager, for a primary CTA
<Link href={href} prefetch={false} /> // off, for a long list of
// rarely-clicked links
// Prefetching a hundred rows costs real bandwidth on a phone. Turn it
// off on long lists and keep it on for the few links people actually
// take.
// Programmatic prefetch on intent
const router = useRouter()
<div onMouseEnter={() => router.prefetch(`/problems/${slug}`)}>
// Scroll behaviour: Next restores position on back/forward and scrolls
// to top on a new navigation, which is correct. Override only for
// in-page filtering:
router.replace(url, { scroll: false })
// And move focus on route change, or a screen-reader user is left
// where they were:
useEffect(() => { headingRef.current?.focus() }, [pathname])
// with tabIndex={-1} on the heading.Key Points to Remember
- 1A Link click fetches from the server, so nothing changes on screen until it lands — the app can feel frozen on a slow connection
- 2useLinkStatus gives a per-link pending state; a useTransition around router.push does the same on older versions
- 3A global progress bar needs Suspense around useSearchParams or it makes the tree dynamic
- 4Prefetching happens in production only, which is why dev feels slower — measure perceived speed on a build
- 5Disable prefetch on long lists of rarely-clicked links, and move focus to the heading on route change
Interview Questions
Sign in to ask AriaWhy can App Router navigation feel unresponsive on a slow connection?
Why does a component using useSearchParams need a Suspense boundary?
When would you disable prefetching on a Link?
Ask Aria about Navigation Feel — Pending States and Transitions
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.