🧭 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:
- Before you click, it quietly downloads the code and data for the destination — in the background, at low priority.
- When you click, it swaps only the parts of the page that changed, keeping the shared layouts mounted.
- 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:
// 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>
)
}
// 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:
<Link href="/about" className="underline" aria-label="About us">
About
</Link>
Dynamic hrefs
// 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>
)
}
// 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:
<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:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
typedRoutes: true,
}
export default nextConfig
<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
// 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 value | Behavior |
|---|---|
null (default) | Prefetches the shared layout plus the first loading boundary. Enough to show something instantly. |
true | Prefetches the full page payload, including data below the loading boundary. |
false | No 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:
// 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:
// 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?
// 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>
)
}
// 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:
const isActive = href === '/' ? pathname === '/' : pathname.startsWith(href)
useRouter — navigate from code
// 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>
}
// 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:
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, notnext/router.next/routeris 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
// 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>
)
}
// 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>:
// 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
// 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>
}
// 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:
// 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>
)
}
// 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>:
// 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
}
// app/components/Nav.tsx
import Link from 'next/link'
import { LinkSpinner } from './LinkSpinner'
export function Nav() {
return (
<Link href="/reports">
Reports
<LinkSpinner />
</Link>
)
}
// 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:
// 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>
}
// 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>
}
import { permanentRedirect } from 'next/navigation'
permanentRedirect('/new-url') // 308 — permanent, cached by browsers
Two things to know about redirect():
- It throws. Code after it does not run, and you don't need to
returnit. - Never call it inside a
tryblock — thecatchwill swallow the control-flow signal and the redirect won't happen.
// ❌ 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.
<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
// ❌ 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'
// ❌ 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:
<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
// ❌ 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:
'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?
'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.
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-youafter 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
<Link>is the default for internal navigation. It prefetches, soft-navigates, and preserves layout state —<a>throws all of that away.- Import navigation hooks from
next/navigation.next/routerbelongs to the Pages Router and does not exist here. - Prefetching is off in development. Measure navigation performance against a production build or you'll chase phantom slowness.
useSearchParamsneeds a Suspense boundary, otherwise the route degrades to client-side rendering during static generation.redirect()works by throwing — never call it inside atryblock, 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.