Dev Logs
/Next.js/ Chapter 4: Linking & Navigation
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
  • 04Chapter 4: Linking & Navigation
    • Plain English Explanation
    • <Link>
    • Dynamic hrefs
    • Typed hrefs
    • Useful props
    • Prefetching
    • What gets prefetched
    • Next.js 16 made prefetching much cheaper
    • Instant Navigations (16.3)
    • Navigation hooks
    • usePathname — where am I?
    • useRouter — navigate from code
    • useSearchParams — read the query string
    • useParams — read dynamic segments on the client
    • useSelectedLayoutSegment — which child is active?
    • useLinkStatus — show a pending state
    • Redirecting from the server
    • Soft vs hard navigation
    • Common Pitfalls
    • . Importing from next/router
    • . Using a navigation hook without 'use client'
    • . useSearchParams without Suspense
    • . redirect() inside try/catch
    • . Judging navigation speed in dev mode
    • . Prefetching a giant list
    • . Expecting router.push to await the navigation
    • When & Why to Use
    • Mini Practice Problems
    • Problem 1: Active link edge case
    • Problem 2: Find the bugs
    • Problem 3: Preserve the query string
    • Problem 4: Pick the tool
    • Interview Notes
    • Common Questions
    • Asked at Companies
    • Visual Memory Aid
    • Key Takeaways
  • 05Chapter 5: Dynamic Routes & Params
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
  • 08Chapter 8: Loading, Suspense & Streaming
  • 09Chapter 9: Error Handling
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
  • 12Chapter 12: Server Actions & Mutations
  • 13Chapter 13: Route Handlers
  • 14Chapter 14: Caching & use cache
  • 15Chapter 15: Revalidation & ISR
  • 16Chapter 16: Cache Components & Partial Prerendering
  • 17Chapter 17: Proxy (formerly Middleware)
  • 18Chapter 18: Authentication & Authorization
  • 19Chapter 19: Metadata, SEO & OG Images
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
  • 22Chapter 22: Performance Optimization
  • 23Chapter 23: Testing
  • 24Chapter 24: Deployment & Self-Hosting
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
  • 27Chapter 27: React Performance Profiling
All chapters

🧭 Chapter 4: Linking & Navigation

How Next.js makes clicking a link feel instant, the hooks that tell you where you are, and how to navigate from code.

📖 Plain English Explanation

A plain <a href="/about"> throws away everything. The browser tears down the page, requests a fresh document, re-downloads the CSS and JavaScript, and rebuilds from zero. You see a white flash. Any state you had is gone.

Next.js's <Link> does something smarter:

  1. Before you click, it quietly downloads the code and data for the destination — in the background, at low priority.
  2. When you click, it swaps only the parts of the page that changed, keeping the shared layouts mounted.
  3. The URL updates without a page load.

The result is that navigation feels instant, because by the time you click, the work is often already done.

That's the whole idea. The rest of this chapter is the details: how to control the prefetching, how to know which link is active, and how to navigate without a link at all.

🔗 <Link>

Import it from next/link and use it exactly like an anchor tag:

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

export default function Page() {
  return (
    <nav>
      <Link href="/">Home</Link>
      <Link href="/about">About</Link>
      <Link href="/blog/hello-world">A post</Link>
    </nav>
  )
}
jsx
// app/page.js
import Link from 'next/link'

export default function Page() {
  return (
    <nav>
      <Link href="/">Home</Link>
      <Link href="/about">About</Link>
      <Link href="/blog/hello-world">A post</Link>
    </nav>
  )
}

<Link> renders a real <a> element, so middle-click, ⌘-click, right-click → "Open in new tab", and screen readers all work normally. Any prop you pass through lands on the anchor:

tsx
<Link href="/about" className="underline" aria-label="About us">
  About
</Link>

Dynamic hrefs

tsx
// app/blog/page.tsx
import Link from 'next/link'

export default async function Page() {
  const posts = await getPosts()

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <Link href={`/blog/${post.slug}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  )
}
jsx
// app/blog/page.js
import Link from 'next/link'

export default async function Page() {
  const posts = await getPosts()

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <Link href={`/blog/${post.slug}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  )
}

Or the object form, which handles query strings for you:

tsx
<Link
  href={{
    pathname: '/shop',
    query: { category: 'shoes', sort: 'price' },
  }}
>
  Shoes, cheapest first
</Link>
// →  /shop?category=shoes&sort=price

Typed hrefs

Turn on typedRoutes and typos become compile errors:

ts
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  typedRoutes: true,
}

export default nextConfig
tsx
<Link href="/dashbaord">Dashboard</Link>
//         ^^^^^^^^^^^ Type error: not a valid route

Worth enabling on day one. A 404 caught at build time costs nothing; one caught in production costs a bug report.

Useful props

tsx
// Replace the history entry instead of pushing a new one
<Link href="/step-2" replace>Next step</Link>

// Don't scroll to the top after navigating
<Link href="/feed#comments" scroll={false}>Comments</Link>

// Disable prefetching for this link
<Link href="/huge-report" prefetch={false}>Annual report</Link>

⚡ Prefetching

This is where the "instant" comes from.

When a <Link> enters the viewport, Next.js fetches the destination's payload in the background. By the time the user clicks, the data is usually already in memory.

What gets prefetched

prefetch valueBehavior
null (default)Prefetches the shared layout plus the first loading boundary. Enough to show something instantly.
truePrefetches the full page payload, including data below the loading boundary.
falseNo prefetch on viewport entry. Still prefetches on hover.
'auto'Explicit form of the default
'unstable_forceStale'Serves from cache even if stale

In development, prefetching is disabled — routes compile on demand. Always measure navigation speed against a production build (next build && next start), never next dev.

Next.js 16 made prefetching much cheaper

⚠️ Changed in Next.js 16

The routing and navigation system was rewritten. Two changes matter:

  • Layout deduplication — prefetching ten links that share a layout downloads that layout once, not ten times.
  • Incremental prefetching — Next.js requests only the parts it doesn't already have, instead of whole pages.

No code changes required. You will see more individual network requests with a much smaller total transfer size. That's the intended trade.

Instant Navigations (16.3)

Next.js 16.3 shipped a suite of tools branded "Instant Navigations", aimed at making App Router transitions feel like a single-page app. The relevant knobs:

ts
// next.config.ts
const nextConfig: NextConfig = {
  // Prefetch only the parts of a page likely to be needed
  partialPrefetching: true,
  // Inline small prefetch payloads into the HTML document
  prefetchInlining: true,
}

And a per-route segment config to mark a route as instant:

tsx
// app/dashboard/page.tsx
export const instant = true

These are optimizations, not requirements. Get the app correct first, then reach for them if navigation still feels sluggish under a production build.

🎣 Navigation hooks

All of these are Client Component only. They need 'use client'.

usePathname — where am I?

tsx
// app/components/NavLink.tsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'

export function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
  const pathname = usePathname()
  const isActive = pathname === href

  return (
    <Link
      href={href}
      className={isActive ? 'font-bold text-blue-600' : 'text-gray-600'}
      aria-current={isActive ? 'page' : undefined}
    >
      {children}
    </Link>
  )
}
jsx
// app/components/NavLink.js
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'

export function NavLink({ href, children }) {
  const pathname = usePathname()
  const isActive = pathname === href

  return (
    <Link
      href={href}
      className={isActive ? 'font-bold text-blue-600' : 'text-gray-600'}
      aria-current={isActive ? 'page' : undefined}
    >
      {children}
    </Link>
  )
}

For section highlighting, prefix-match instead of exact-match — but guard the root:

tsx
const isActive = href === '/' ? pathname === '/' : pathname.startsWith(href)

useRouter — navigate from code

tsx
// app/components/LogoutButton.tsx
'use client'
import { useRouter } from 'next/navigation'

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

  async function handleLogout() {
    await fetch('/api/logout', { method: 'POST' })
    router.push('/login')
  }

  return <button onClick={handleLogout}>Log out</button>
}
jsx
// app/components/LogoutButton.js
'use client'
import { useRouter } from 'next/navigation'

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

  async function handleLogout() {
    await fetch('/api/logout', { method: 'POST' })
    router.push('/login')
  }

  return <button onClick={handleLogout}>Log out</button>
}

The full API:

ts
router.push('/dashboard')          // navigate, add a history entry
router.replace('/dashboard')       // navigate, replace the current entry
router.back()                      // browser back
router.forward()                   // browser forward
router.refresh()                   // re-fetch the current route from the server
router.prefetch('/dashboard')      // manually prefetch

Import from next/navigation, not next/router. next/router is the Pages Router API and does not exist in the App Router. This is one of the most common copy-paste errors from old tutorials.

router.refresh() deserves a note: it re-runs the Server Components for the current route and merges the new result into the existing UI, without losing client state or resetting scroll. It's how you say "the data changed, redraw."

useSearchParams — read the query string

tsx
// app/shop/Filters.tsx
'use client'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'

export function Filters() {
  const searchParams = useSearchParams()
  const router = useRouter()
  const pathname = usePathname()

  const category = searchParams.get('category') ?? 'all'

  function setCategory(next: string) {
    const params = new URLSearchParams(searchParams.toString())
    params.set('category', next)
    router.push(`${pathname}?${params.toString()}`)
  }

  return (
    <select value={category} onChange={(e) => setCategory(e.target.value)}>
      <option value="all">All</option>
      <option value="shoes">Shoes</option>
      <option value="hats">Hats</option>
    </select>
  )
}
jsx
// app/shop/Filters.js
'use client'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'

export function Filters() {
  const searchParams = useSearchParams()
  const router = useRouter()
  const pathname = usePathname()

  const category = searchParams.get('category') ?? 'all'

  function setCategory(next) {
    const params = new URLSearchParams(searchParams.toString())
    params.set('category', next)
    router.push(`${pathname}?${params.toString()}`)
  }

  return (
    <select value={category} onChange={(e) => setCategory(e.target.value)}>
      <option value="all">All</option>
      <option value="shoes">Shoes</option>
      <option value="hats">Hats</option>
    </select>
  )
}

The returned object is a read-only URLSearchParams. Copy it before mutating, as above.

Important: a component calling useSearchParams() will opt the whole route into client-side rendering during static generation unless you wrap it in <Suspense>:

tsx
// app/shop/page.tsx
import { Suspense } from 'react'
import { Filters } from './Filters'

export default function Page() {
  return (
    <>
      <h1>Shop</h1>
      <Suspense fallback={<div>Loading filters…</div>}>
        <Filters />
      </Suspense>
    </>
  )
}

useParams — read dynamic segments on the client

tsx
// app/blog/[slug]/ShareButton.tsx
'use client'
import { useParams } from 'next/navigation'

export function ShareButton() {
  const { slug } = useParams<{ slug: string }>()
  return <button onClick={() => navigator.share({ url: `/blog/${slug}` })}>Share</button>
}
jsx
// app/blog/[slug]/ShareButton.js
'use client'
import { useParams } from 'next/navigation'

export function ShareButton() {
  const { slug } = useParams()
  return <button onClick={() => navigator.share({ url: `/blog/${slug}` })}>Share</button>
}

In Server Components use the params prop instead — it's already there and doesn't cost you a client bundle.

useSelectedLayoutSegment — which child is active?

Useful for tab bars inside a layout:

tsx
// app/dashboard/Tabs.tsx
'use client'
import Link from 'next/link'
import { useSelectedLayoutSegment } from 'next/navigation'

const tabs = [
  { slug: null, label: 'Overview', href: '/dashboard' },
  { slug: 'billing', label: 'Billing', href: '/dashboard/billing' },
  { slug: 'settings', label: 'Settings', href: '/dashboard/settings' },
]

export function Tabs() {
  const segment = useSelectedLayoutSegment()

  return (
    <nav className="flex gap-4 border-b">
      {tabs.map((tab) => (
        <Link
          key={tab.href}
          href={tab.href}
          className={segment === tab.slug ? 'border-b-2 border-black' : ''}
        >
          {tab.label}
        </Link>
      ))}
    </nav>
  )
}
jsx
// app/dashboard/Tabs.js
'use client'
import Link from 'next/link'
import { useSelectedLayoutSegment } from 'next/navigation'

const tabs = [
  { slug: null, label: 'Overview', href: '/dashboard' },
  { slug: 'billing', label: 'Billing', href: '/dashboard/billing' },
  { slug: 'settings', label: 'Settings', href: '/dashboard/settings' },
]

export function Tabs() {
  const segment = useSelectedLayoutSegment()

  return (
    <nav className="flex gap-4 border-b">
      {tabs.map((tab) => (
        <Link
          key={tab.href}
          href={tab.href}
          className={segment === tab.slug ? 'border-b-2 border-black' : ''}
        >
          {tab.label}
        </Link>
      ))}
    </nav>
  )
}

useSelectedLayoutSegments() (plural) returns the full array of segments below the current layout.

useLinkStatus — show a pending state

Navigation isn't always instant. If a destination has slow data and no loading.tsx, the user clicks and nothing happens for a moment. useLinkStatus fixes that.

It must be used in a component rendered inside a <Link>:

tsx
// app/components/LinkSpinner.tsx
'use client'
import { useLinkStatus } from 'next/link'

export function LinkSpinner() {
  const { pending } = useLinkStatus()
  return pending ? <span className="ml-2 animate-spin">⏳</span> : null
}
tsx
// app/components/Nav.tsx
import Link from 'next/link'
import { LinkSpinner } from './LinkSpinner'

export function Nav() {
  return (
    <Link href="/reports">
      Reports
      <LinkSpinner />
    </Link>
  )
}
jsx
// app/components/LinkSpinner.js
'use client'
import { useLinkStatus } from 'next/link'

export function LinkSpinner() {
  const { pending } = useLinkStatus()
  return pending ? <span className="ml-2 animate-spin">⏳</span> : null
}

🚦 Redirecting from the server

Two functions from next/navigation, usable in Server Components, Server Actions, and Route Handlers:

tsx
// app/dashboard/page.tsx
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'

export default async function Page() {
  const session = await getSession()
  if (!session) {
    redirect('/login')          // 307 — temporary
  }
  return <h1>Welcome, {session.user.name}</h1>
}
jsx
// app/dashboard/page.js
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'

export default async function Page() {
  const session = await getSession()
  if (!session) {
    redirect('/login')
  }
  return <h1>Welcome, {session.user.name}</h1>
}
tsx
import { permanentRedirect } from 'next/navigation'
permanentRedirect('/new-url')   // 308 — permanent, cached by browsers

Two things to know about redirect():

  1. It throws. Code after it does not run, and you don't need to return it.
  2. Never call it inside a try block — the catch will swallow the control-flow signal and the redirect won't happen.
tsx
// ❌ the redirect is swallowed
try {
  await saveData()
  redirect('/success')
} catch (e) {
  console.error(e)
}

// ✅ redirect outside the try
let ok = false
try {
  await saveData()
  ok = true
} catch (e) {
  console.error(e)
}
if (ok) redirect('/success')

If you must redirect inside a try, re-throw the framework's control-flow errors with unstable_rethrow (Chapter 9).

🔀 Soft vs hard navigation

SOFT navigation  (what <Link> and router.push do)
  → only changed segments re-render
  → layouts stay mounted, client state survives
  → no full page load

HARD navigation  (what <a href> and window.location do)
  → entire document reloads
  → all React state is destroyed
  → white flash

Use <Link> for internal routes. Use <a> only for external URLs, downloads, and mailto:/tel: links.

tsx
<Link href="/about">About</Link>                                    {/* internal */}
<a href="https://github.com" target="_blank" rel="noopener">GitHub</a>  {/* external */}
<a href="/report.pdf" download>Download PDF</a>                      {/* download */}

⚠️ Common Pitfalls

1. Importing from next/router

tsx
// ❌ Pages Router — does not exist in app/
import { useRouter } from 'next/router'

// ✅
import { useRouter } from 'next/navigation'

The error message (NextRouter was not mounted) is unhelpful. Check the import first.

2. Using a navigation hook without 'use client'

tsx
// ❌ app/page.tsx
import { usePathname } from 'next/navigation'
export default function Page() {
  const pathname = usePathname()   // Error: hooks only work in Client Components
}

Fix: add 'use client', or better, extract just the interactive piece into its own client component so the page stays on the server.

3. useSearchParams without Suspense

Error: useSearchParams() should be wrapped in a suspense boundary at page "/shop"

Fix: wrap the component in <Suspense>. Without it, the entire route falls back to client rendering during static generation.

4. redirect() inside try/catch

Covered above — the single most common navigation bug in App Router codebases.

5. Judging navigation speed in dev mode

Prefetching is off in next dev and routes compile on demand. A page that takes 800ms in development can be instant in production. Always benchmark with next build && next start.

6. Prefetching a giant list

A table with 500 rows, each containing a <Link>, will try to prefetch 500 routes as they scroll into view. Next.js 16's deduplication and incremental prefetching soften this considerably, but for very large lists it's still worth opting out:

tsx
<Link href={`/orders/${order.id}`} prefetch={false}>
  {order.reference}
</Link>

Hover-prefetch still works, so the UX cost is close to zero.

7. Expecting router.push to await the navigation

tsx
// ❌ this doesn't wait
router.push('/dashboard')
console.log('arrived')   // logs immediately, before the navigation finishes

router.push returns undefined. To render a pending state, use useTransition:

tsx
'use client'
import { useTransition } from 'react'
import { useRouter } from 'next/navigation'

export function SaveButton() {
  const router = useRouter()
  const [isPending, startTransition] = useTransition()

  return (
    <button
      disabled={isPending}
      onClick={() => startTransition(() => router.push('/dashboard'))}
    >
      {isPending ? 'Loading…' : 'Go to dashboard'}
    </button>
  )
}

🎯 When & Why to Use

<Link>              →  default for every internal navigation
<a>                 →  external URLs, downloads, mailto:, tel:
router.push()       →  after a form submit, a login, a programmatic flow
router.replace()    →  wizards and auth flows where back shouldn't return
router.refresh()    →  after a mutation, to re-fetch server data in place
redirect()          →  server-side gating (auth checks, feature flags)
permanentRedirect() →  a URL moved for good and search engines should know

prefetch={false}    →  very large lists, or heavy pages rarely visited
useLinkStatus       →  destinations without a loading.tsx that feel slow

🏋️ Mini Practice Problems

Problem 1: Active link edge case

This NavLink highlights / on every single page. Why, and what's the fix?

tsx
'use client'
export function NavLink({ href, children }) {
  const pathname = usePathname()
  return (
    <Link href={href} className={pathname.startsWith(href) ? 'active' : ''}>
      {children}
    </Link>
  )
}

Problem 2: Find the bugs

Three problems here. Name them all.

tsx
import { useRouter } from 'next/router'

export default function Page() {
  const router = useRouter()
  return <button onClick={() => router.push('/next')}>Continue</button>
}

Problem 3: Preserve the query string

Write a Client Component with a "sort by price" button that adds ?sort=price to the URL without discarding any filters already in the query string.

Problem 4: Pick the tool

Which navigation API for each?

  • A. A logo in the header linking home
  • B. Redirecting an unauthenticated user away from /dashboard
  • C. Going to /thank-you after a checkout form succeeds, so back doesn't resubmit
  • D. Refreshing a list after deleting a row, without losing the scroll position
  • E. Linking to your company's status page on another domain

💼 Interview Notes

Common Questions

Q: What does <Link> do that <a> doesn't? Prefetches the destination when it enters the viewport, performs a client-side soft navigation that only re-renders changed segments, preserves layout state, and updates the URL without a document reload.

Q: How does prefetching work in the App Router? Links in the viewport are prefetched at low priority. By default Next.js fetches the shared layout plus the first loading boundary; prefetch={true} fetches the full payload. Next.js 16 deduplicates shared layouts across links and fetches only missing pieces. Prefetching is disabled in development.

Q: Difference between router.push and router.replace? push adds a history entry so back returns to the previous page. replace overwrites the current entry — used for auth flows and multi-step forms where going back would be wrong.

Q: What does router.refresh() do? Re-runs Server Components for the current route and reconciles the result into the existing tree. Client state and scroll position are preserved. Use it after a mutation to pull fresh server data.

Q: Why must useSearchParams be inside a Suspense boundary? The query string isn't known at build time. Reading it makes the component dynamic, so React needs a boundary to stream the static shell first. Without one, the entire route degrades to client-side rendering.

Q: Why shouldn't redirect() be called inside a try block? It signals control flow by throwing a special error. A surrounding catch intercepts that error and the redirect silently never happens.

🏢 Asked at Companies

  • Vercel: "Walk through everything that happens from a <Link> entering the viewport to the new page being visible."
  • Stripe: "A user reports the back button re-submits their payment. What did the developer do wrong?"
  • Airbnb: "Search results page with 200 links — what's your prefetching strategy and why?"
  • Shopify: "Users say navigation is slow in dev but the team can't reproduce it in staging. Diagnose."

📊 Visual Memory Aid

              LIFECYCLE OF A CLICK

  1. Link enters viewport
        │
        └─► prefetch payload in background (idle priority)

  2. User hovers
        │
        └─► prefetch (even if prefetch={false})

  3. User clicks
        │
        ├─► payload cached?  ──yes──► render instantly
        └─► not cached?      ──────► show loading.tsx, stream in

  4. Only changed segments re-render
        Shared layouts stay mounted, state survives


              NAVIGATION APIs BY CONTEXT

  ┌─────────────────┬──────────────┬─────────────────────┐
  │                 │ Server       │ Client              │
  ├─────────────────┼──────────────┼─────────────────────┤
  │ Declarative     │ <Link>       │ <Link>              │
  │ Programmatic    │ redirect()   │ useRouter().push()  │
  │ Read pathname   │ (via params) │ usePathname()       │
  │ Read query      │ searchParams │ useSearchParams()   │
  │ Read segments   │ params       │ useParams()         │
  └─────────────────┴──────────────┴─────────────────────┘

  All client hooks import from 'next/navigation'
  (never 'next/router' — that's the Pages Router)

🎯 Key Takeaways

  1. <Link> is the default for internal navigation. It prefetches, soft-navigates, and preserves layout state — <a> throws all of that away.
  2. Import navigation hooks from next/navigation. next/router belongs to the Pages Router and does not exist here.
  3. Prefetching is off in development. Measure navigation performance against a production build or you'll chase phantom slowness.
  4. useSearchParams needs a Suspense boundary, otherwise the route degrades to client-side rendering during static generation.
  5. redirect() works by throwing — never call it inside a try block, and don't bother returning it.

Next Chapter: Dynamic Routes & Params →

Practice: Build a header with three NavLinks that correctly highlight the active route (including the / edge case), plus a "Refresh data" button using router.refresh(). Then run next build && next start and compare navigation speed to next dev.


PreviousChapter 3: Layouts & PagesNextChapter 5: Dynamic Routes & Params

Open source, free forever. Built by iammhador.

Contribute on GitHub