| | |

Next.js 04 ▲ Linking and Navigating — Prefetching, client-side navigation & instant insights

⏱️ Estimated read: 13 min | |

The Link component is the primary navigation primitive in the App Router, and it extends the HTML <a> element with two behaviors that make navigation feel instant: prefetching and client-side transitions. The previous chapter introduced the Link and the basic navigation; this chapter explains the mechanism behind the instant feel, the prefetching policy that decides what is loaded ahead of time, the programmatic navigation with useRouter, and the pending-state feedback with useLinkStatus.

The prefetching is the process of loading a route in the background before the user navigates to it. Next.js automatically prefetches the routes linked with the <Link> component when they enter the viewport, but how much is prefetched depends on whether the route is static or dynamic . The client-side transition is the mechanism that swaps the content without a full page reload, which preserves the layout state and the scroll position . The useLinkStatus hook is the new addition that lets a component show an inline pending indicator when the prefetch has not completed and the navigation is blocked .

Key point: The prefetching runs only in production. A static route is prefetched in full, and a dynamic route is skipped unless it has a loading.tsx boundary, in which case the layout and the loading skeleton are prefetched . The prefetch prop on the Link overrides the default: true prefetches the full route regardless of the type, and false disables the prefetching . The useRouter hook is for the programmatic navigation from an event handler, and the <Link> is the recommended default for the navigation between the routes .


Why prefetching and client-side navigation require the route type

The static-versus-dynamic problem.

Next.js prefetches a static route in full when the link enters the viewport, because the route is prerendered at the build time and the full payload is available . A dynamic route is different: it is rendered at the request time, so the prefetch is either skipped or limited to the layout and the loading skeleton if the route has a loading.tsx boundary . The route type is the variable that determines the prefetch behavior, and the loading.tsx is the mechanism that makes a dynamic route partially prefetchable.

The prefetch-scheduling problem.

The prefetches do not all fire at once. Next.js maintains a small task queue: the links in the viewport are prefetched first, then the links showing the user intent (the hover or the touch), then the newer links replace the older ones, and the links scrolled off-screen are discarded . The scheduler is the mechanism that prioritizes the likely navigations while minimizing the unused downloads.

The pending-state problem.

The prefetch makes the navigation instant when the prefetch has completed. But when the prefetch is disabled, in progress, or skipped for a dynamic route without a loading.tsx, the click triggers a server roundtrip and the navigation is blocked for the duration. The useLinkStatus hook is the mechanism that detects this pending state and lets the component show the inline feedback, such as a shimmer or a spinner . The hook must be used inside a descendant component of a <Link>, and it is most useful when the prefetch={false} is set on the link.


a. The prefetching policy and the Link component

The Link component prefetches the route automatically when it enters the viewport, but the amount depends on the route type and the loading.tsx.

import Link from 'next/link'

export default function Navigation() {
  return (
    <nav>
      {/* Static route: prefetched in full */}
      <Link href="/about">About</Link>

      {/* Dynamic route without loading.tsx: skipped */}
      <Link href="/blog/hello-world">Post</Link>

      {/* Dynamic route with loading.tsx: partial prefetch */}
      <Link href="/dashboard">Dashboard</Link>
    </nav>
  )
}

The /about route is static if it has no dynamic data, so the full route is prefetched . The /blog/hello-world route is dynamic if it uses the params or the searchParams, and it is skipped unless the app/blog/[slug]/loading.tsx exists . The /dashboard route is dynamic, but if it has the app/dashboard/loading.tsx, the layout and the loading skeleton are prefetched, and the navigation shows the skeleton instantly while the data streams in.

The prefetch prop overrides the default behavior.

{/* Force the full prefetch for a dynamic route */}
<Link href="/blog/hello-world" prefetch={true}>Post</Link>

{/* Disable the prefetch for a heavy route */}
<Link href="/heavy-report" prefetch={false}>Report</Link>

The prefetch={true} tells Next.js to prefetch the full route even for a dynamic route, which is useful when the navigation to that route is critical . The prefetch={false} disables the prefetching, which is useful when the route is heavy and the user is unlikely to visit it, or when the prefetch would compete with the current page’s data.


b. The useLinkStatus hook for the pending feedback

The useLinkStatus hook returns a pending boolean that is true while the navigation is in progress and the new route has not been committed.

'use client'

import Link from 'next/link'
import { useLinkStatus } from 'next/link'

function LoadingHint() {
  const { pending } = useLinkStatus()
  return (
    <span
      aria-hidden
      className={`hint ${pending ? 'is-pending' : ''}`}
      style={{ opacity: pending ? 1 : 0 }}
    />
  )
}

export function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
  return (
    <Link href={href} prefetch={false}>
      {children}
      <LoadingHint />
    </Link>
  )
}

The LoadingHint is a descendant of the Link, which is the requirement for the hook . The pending is true when the click triggers the navigation and the prefetch has not completed, and it becomes false when the history is updated and the new route is committed . The hook is most useful when the prefetch={false} is set, because the prefetched routes skip the pending state entirely .

The hook should not be used as a substitute for the loading.tsx. The loading.tsx is the route-level fallback that shows the skeleton instantly, while the useLinkStatus is the inline hint that confirms the click while the navigation is blocked. The recommendation is to use the loading.tsx for the route-level feedback and the useLinkStatus only for the subtle inline hint .


c. The useRouter hook for the programmatic navigation

The useRouter hook is for the navigation from an event handler, such as a form submission or a button click. It is imported from next/navigation, not next/router.

'use client'

import { useRouter } from 'next/navigation'

export function LoginButton() {
  const router = useRouter()

  const handleLogin = async () => {
    await login()
    router.push('/dashboard')
  }

  return <button onClick={handleLogin}>Log in</button>
}

The router.push('/dashboard') performs the client-side navigation and adds a new entry to the browser history . The router.replace('/dashboard') replaces the current entry, which is the correct choice after the login redirect because the user should not be able to go back to the login page with the back button .

The router.refresh() is the mechanism that re-fetches the current route’s data without losing the client state.

const handleRefresh = () => {
  router.refresh()
}

The router.refresh() makes a new request to the server, re-fetches the data, and re-renders the Server Components, while preserving the client-side React state and the browser state . The method is useful when the data has changed on the server and the client needs to see the update without a full navigation.


Complete Example Session

// ============================================
// PART 1: THE STATIC LINK
// ============================================
import Link from 'next/link'

export function StaticNav() {
  return <Link href="/about">About</Link>
}

// ============================================
// PART 2: THE DYNAMIC LINK WITH LOADING
// ============================================
// app/dashboard/loading.tsx
export default function Loading() {
  return <div className="skeleton" />
}

// app/dashboard/page.tsx
export default async function Dashboard() {
  const data = await fetchData()
  return <div>{data}</div>
}

// app/layout.tsx
import Link from 'next/link'

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <nav>
      <Link href="/dashboard">Dashboard</Link>
      {children}
    </nav>
  )
}

// ============================================
// PART 3: THE PREFETCH OVERRIDE
// ============================================
export function HeavyLink() {
  return <Link href="/report" prefetch={false}>Report</Link>
}

// ============================================
// PART 4: THE PENDING HINT
// ============================================
'use client'
import Link from 'next/link'
import { useLinkStatus } from 'next/link'

function Hint() {
  const { pending } = useLinkStatus()
  return <span className={pending ? 'pending' : ''} />
}

export function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
  return (
    <Link href={href} prefetch={false}>
      {children}
      <Hint />
    </Link>
  )
}

// ============================================
// PART 5: THE PROGRAMMATIC NAVIGATION
// ============================================
'use client'
import { useRouter } from 'next/navigation'

export function LoginButton() {
  const router = useRouter()
  return (
    <button onClick={() => router.push('/dashboard')}>Log in</button>
  )
}

// ============================================
// PART 6: THE REPLACE NAVIGATION
// ============================================
export function LogoutButton() {
  const router = useRouter()
  return (
    <button onClick={() => router.replace('/login')}>Log out</button>
  )
}

// ============================================
// PART 7: THE REFRESH
// ============================================
export function RefreshButton() {
  const router = useRouter()
  return <button onClick={() => router.refresh()}>Refresh</button>
}

The seven parts demonstrate the navigation surface: the static link, the dynamic link with the loading boundary, the prefetch override, the pending hint, the programmatic navigation, the replace navigation, and the refresh. The critical distinction is between Parts 1 and 3: the static link prefetches in full, and the prefetch={false} disables it.


Quick Reference

Link Prefetch Behavior

Route TypeDefault PrefetchWith loading.tsx
StaticFull routeFull route
DynamicSkippedLayout + loading skeleton
prefetch={true}Full routeFull route
prefetch={false}NoneNone

Link Props

PropPurpose
hrefThe destination
prefetchOverride the prefetch (true, false, null)
replaceReplace the history entry
scrollControl the scroll restoration

Navigation Hooks

HookImportPurpose
useRouternext/navigationProgrammatic navigation
usePathnamenext/navigationThe current pathname
useLinkStatusnext/linkThe pending state of a Link

Router Methods

MethodPurpose
router.push(href)Navigate, add history entry
router.replace(href)Navigate, replace history entry
router.refresh()Re-fetch the current route
router.back()Go back
router.prefetch(href)Prefetch manually

Best Practices

✅ Do This:

// Use the Link for the navigation
<Link href="/about">About</Link>                               # ✅

// Add the loading.tsx for the dynamic routes
app/dashboard/loading.tsx                                      # ✅

// Use the prefetch={false} for the heavy, unlikely routes
<Link href="/report" prefetch={false}>Report</Link>            # ✅

// Use the useLinkStatus inside the Link for the pending hint
<Link href="/slow"><Hint /></Link>                             # ✅

// Use the useRouter for the event-driven navigation
router.push('/dashboard')                                      # ✅

// Use the replace after the login redirect
router.replace('/dashboard')                                   # ✅

❌ Don’t Do This:

// Use the <a> element for the internal navigation
<a href="/about">About</a>    // Full page reload               # ❌

// Forget the loading.tsx for the dynamic route
// The navigation shows nothing until the server responds       # ❌

// Use the useLinkStatus outside a Link
function Hint() { useLinkStatus() }    // Error                 # ❌

// Use the useLinkStatus as the only loading indicator
// The loading.tsx is the route-level fallback                  # ❌

// Use the router.push for the login redirect
router.push('/dashboard')    // The back button returns to login # ❌

// Use the useRouter from next/router
import { useRouter } from 'next/router'    // Wrong import     # ❌

Common Pitfalls

PitfallWhy It HappensFix
The navigation reloads the pageUsed the <a> elementUse the Link
The dynamic route shows nothingNo loading.tsxAdd the loading.tsx
The pending hint never showsThe route was prefetchedSet prefetch={false}
The useLinkStatus errorUsed outside a LinkMove it inside the Link
The login redirect has the back buttonUsed router.pushUse router.replace
The import errorUsed next/routerUse next/navigation

Real-World Examples

1. Static link

<Link href="/about">About</Link>

2. Dynamic link with loading

<Link href="/dashboard">Dashboard</Link>
// app/dashboard/loading.tsx

3. Prefetch disabled

<Link href="/report" prefetch={false}>Report</Link>

4. Prefetch forced

<Link href="/blog/post" prefetch={true}>Post</Link>

5. Pending hint

'use client'
import { useLinkStatus } from 'next/link'
const { pending } = useLinkStatus()

6. Programmatic push

router.push('/dashboard')

7. Programmatic replace

router.replace('/login')

8. Refresh

router.refresh()

9. Active link

'use client'
const pathname = usePathname()
<Link className={pathname === '/' ? 'active' : ''} href="/">Home</Link>

10. Scroll control

<Link href="/about" scroll={false}>About</Link>

Visual

The Prefetch Decision

┌──────────────────────────────────────────────────────────────┐
│  THE PREFETCH DEPENDS ON THE ROUTE TYPE                      │
│                                                              │
│  Static route:                                               │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ The full route is prefetched when the link enters the  │  │
│  │ viewport. The navigation is instant.                   │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  Dynamic route without loading.tsx:                          │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ The prefetch is skipped. The click triggers a server   │  │
│  │ roundtrip, and the navigation is blocked until the     │  │
│  │ response arrives.                                      │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  Dynamic route with loading.tsx:                             │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ The layout and the loading skeleton are prefetched.    │  │
│  │ The click shows the skeleton instantly, and the data   │  │
│  │ streams in.                                            │  │
│  └────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘

The useLinkStatus Pending

┌──────────────────────────────────────────────────────────────┐
│  THE PENDING STATE IS TRUE WHILE THE NAVIGATION IS BLOCKED   │
│                                                              │
│  <Link href="/slow" prefetch={false}>                        │
│    Click                                                     │
│      │                                                       │
│      ▼                                                       │
│  useLinkStatus().pending = true                              │
│      │                                                       │
│      ▼                                                       │
│  The new route is committed                                  │
│      │                                                       │
│      ▼                                                       │
│  useLinkStatus().pending = false                             │
│                                                              │
│  If the route was prefetched, the pending state is skipped.  │
└──────────────────────────────────────────────────────────────┘

The Router Methods

┌──────────────────────────────────────────────────────────────┐
│  PUSH VS REPLACE                                             │
│                                                              │
│  router.push('/dashboard'):                                  │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ Adds a new history entry.                              │  │
│  │ The back button returns to the previous page.          │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  router.replace('/login'):                                   │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ Replaces the current history entry.                    │  │
│  │ The back button does not return to the replaced page.  │  │
│  │ Use it after the login redirect and the logout.        │  │
│  └────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Navigation primitive<Link> from next/link
PrefetchingAutomatic in production
Static route prefetchFull route
Dynamic route prefetchSkipped, or partial with loading.tsx
prefetch proptrue, false, null
Pending hintuseLinkStatus from next/link
Programmatic navigationuseRouter from next/navigation
Pushrouter.push (history entry)
Replacerouter.replace (no history entry)
Refreshrouter.refresh()

Key takeaways:

  • The prefetching makes the navigation instant. Next.js prefetches the routes linked with the <Link> when they enter the viewport. A static route is prefetched in full, and a dynamic route is skipped unless it has a loading.tsx boundary, which enables the partial prefetch of the layout and the loading skeleton .
  • The prefetch prop overrides the default. The prefetch={true} forces the full prefetch for a dynamic route, and the prefetch={false} disables it. The choice depends on the route’s weight and the likelihood of the visit .
  • The client-side transition preserves the state. The <Link> performs the client-side navigation that keeps the shared layouts and the UI, and replaces the current page with the prefetched content. The <a> element triggers the full page reload and loses the state .
  • The useLinkStatus provides the inline pending feedback. The hook returns the pending boolean that is true while the navigation is blocked. It must be used inside a <Link> descendant, and it is most useful when the prefetch={false} is set .
  • The useRouter is for the programmatic navigation. The router.push adds a history entry, and the router.replace replaces the current entry, which is the correct choice after the login redirect. The router.refresh re-fetches the current route’s data .
  • The loading.tsx is the route-level fallback. It is the mechanism that makes a dynamic route partially prefetchable and shows the skeleton instantly while the data streams in. The useLinkStatus is the inline hint, not a substitute for the route-level fallback .

Remember: The Next.js navigation is built on the <Link> component, the prefetching, and the client-side transitions. The static routes are prefetched in full, and the dynamic routes are skipped unless the loading.tsx enables the partial prefetch. The prefetch prop overrides the default, and the useLinkStatus provides the pending hint when the navigation is blocked. The useRouter is for the programmatic navigation, and the router.replace is the correct choice after the login redirect. The result is a navigation that feels instant in the common case, and that shows the feedback in the uncommon case where the server roundtrip is unavoidable.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!