Home/Learn/Next.js/Navigation Feel — Pending States and Transitions

Navigation Feel — Pending States and Transitions

Advanced
UX & Assets

App 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.

useLinkStatus, or a transition you drive
// 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 bar, started on click, finished on route change
// 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.

Prefetch the likely, not the hundred
// 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 Aria
1

Why can App Router navigation feel unresponsive on a slow connection?

Hard
2

Why does a component using useSearchParams need a Suspense boundary?

Hard
3

When would you disable prefetching on a Link?

Medium

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.

Loading discussion…