Next/link and navigation in Next.js
next/link is a React component that handles client-side navigation in Next.js by intercepting anchor clicks, preventing full page reloads, and prefetching routes before the user ever clicks.
Theory
TL;DR
Linkis a smart<a>tag: same HTML output, no page reload, automatic prefetch on viewport entry or hoveruseRouter().push()is for programmatic navigation after logic runs (form submit, API response)redirect()runs on the server, before HTML reaches the client, no'use client'neededuseRouterfromnext/navigation(App Router) andnext/router(Pages Router) are different hooks with different APIs- Default
Linkprefetches on viewport entry; useprefetch={false}for rarely visited pages
Quick Example
// Basic navigation bar - pages/index.tsx
import Link from 'next/link'
export default function Nav() {
return (
<nav>
{/* Prefetches /about when link enters the viewport */}
<Link href="/about">About</Link>
{/* Skip prefetch for rarely visited admin pages */}
<Link href="/admin/settings" prefetch={false}>Settings</Link>
</nav>
)
}
// Clicking "About" swaps page content without a full reloadThe Link component renders a regular <a> in the DOM. The difference is what happens on click: Next.js intercepts it, calls event.preventDefault(), and handles the transition through the router instead of the browser.
Link vs useRouter vs redirect
Three tools, three contexts. Link lives in JSX and handles clicks declaratively. useRouter gives you a programmatic handle for navigating after async work completes. redirect() runs on the server, before any HTML reaches the client.
The decision rule is straightforward. If a user clicks something and goes somewhere, use Link. If code decides where to go after a form submit or login check, use useRouter. If a Server Component or Server Action needs to redirect, use redirect().
When to Use
- Nav bars, cards, breadcrumbs, any clickable element that goes somewhere:
Link - After form submission, search, login success:
useRouter().push() - Auth guards in Server Components, post-mutation redirects in Server Actions:
redirect() - Routes where the back button should skip the current page (modals, login flows):
replaceprop onLinkorrouter.replace() - Rarely visited or deep admin pages:
Linkwithprefetch={false}
Comparison Table
| Feature | Link | useRouter().push() | redirect() |
|---|---|---|---|
| Where it runs | Client (JSX) | Client (event handler) | Server (component/action) |
| Prefetch | Automatic | No | N/A |
| Adds to history | Yes, default | Yes | N/A |
Needs 'use client' | No | Yes | No |
| Use case | Nav UI | Post-logic navigation | Auth guards, mutations |
How Prefetching Works
When a Link enters the viewport (roughly within three viewport heights, per Next.js 14 behavior), Next.js fires a background fetch for that route's RSC payload and caches it in memory. For static routes, it loads the full payload. For dynamic routes, it loads up to the nearest loading.tsx boundary. The result: page swaps feel instant, typically under 200ms.
On mobile or low-bandwidth connections, too many links prefetching simultaneously can hurt performance. Admin pages and rarely visited routes are good candidates for prefetch={false}, with router.prefetch(url) called manually on user intent instead.
Active Links and usePathname
'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()
return (
<Link
href={href}
className={pathname === href ? 'font-bold text-blue-600' : 'text-neutral-600'}
>
{children}
</Link>
)
}usePathname is client-only, so the component needs 'use client'. The Link component itself does not.
useRouter Methods
'use client'
import { useRouter } from 'next/navigation'
export function SearchBar() {
const router = useRouter()
function handleSearch(query: string) {
router.push(`/search?q=${query}`) // adds to history
}
return <input onChange={(e) => handleSearch(e.target.value)} />
}| Method | What it does |
|---|---|
router.push(url) | Navigate to URL, adds to history stack |
router.replace(url) | Navigate without adding to history |
router.back() | Go back in history |
router.forward() | Go forward in history |
router.refresh() | Re-fetches current route data from server |
router.prefetch(url) | Manually prefetch a route |
Note: useRouter from next/navigation has no router.query. For query params in App Router, use useSearchParams.
Server-Side Navigation with redirect
// app/dashboard/page.tsx - Server Component
import { redirect } from 'next/navigation'
import { getUser } from '@/lib/auth'
export default async function DashboardPage() {
const user = await getUser()
if (!user) redirect('/login') // throws internally, Next.js handles it
if (!user.hasAccess) redirect('/upgrade')
return <Dashboard user={user} />
}// app/actions.ts - Server Action
'use server'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const post = await db.post.create({ data: parseForm(formData) })
redirect(`/posts/${post.id}`) // runs after mutation, no client JS
}redirect() throws an internal error that Next.js catches, so no return statement is needed after it.
useSearchParams
'use client'
import { useSearchParams } from 'next/navigation'
export function FilterPanel() {
const searchParams = useSearchParams()
const difficulty = searchParams.get('difficulty') // reads ?difficulty=hard
return <p>Filter: {difficulty || 'all'}</p>
}Use useSearchParams anywhere you need query string values in a Client Component. The App Router has no router.query.
Common Mistakes
Using <a> instead of Link in App Router
// Full page reload, loses scroll restoration and prefetch cache
<a href="/dashboard">Dashboard</a>
// Correct
<Link href="/dashboard">Dashboard</Link>Regular <a> bypasses the Next.js router entirely. The browser reloads the page and throws away every cached RSC payload.
Forgetting replace on login redirects
// After login, push adds /login to history
// User hits back and lands back on the login form
router.push('/dashboard')
// replace overwrites the history entry instead
router.replace('/dashboard')
// Or declaratively:
<Link href="/dashboard" replace>Continue</Link>This is a common production bug. The back button loops the user back to the login page.
Using useRouter in Server Components
// Throws at runtime. useRouter is client-only.
export default async function Page() {
const router = useRouter() // Error!
}
// Correct: use redirect() on the server
import { redirect } from 'next/navigation'
export default async function Page() {
const user = await getUser()
if (!user) redirect('/login')
}Mixing up next/navigation and next/router
I have seen this one kill an entire afternoon of debugging. Both imports compile without errors. The problem shows up at runtime when router.query is undefined in the App Router.
// Pages Router only - has router.query
import { useRouter } from 'next/router'
// App Router - no router.query, use useSearchParams
import { useRouter } from 'next/navigation'
import { useSearchParams } from 'next/navigation'Real-World Usage
- Vercel dashboard:
Linkfor sidebar navigation between projects and deployments - shadcn/ui nav components:
Linkwrapped withusePathnamefor active state styling - T3 Stack apps:
Linkfor protected routes post-signin,redirect()in auth callbacks - Filter pages (search, e-commerce):
useRouter().push()to update URL after user selects filters
Follow-Up Questions
Q: What is the difference between push and replace?
A: push adds a new entry to the browser history stack, so the back button works normally. replace overwrites the current entry. Use replace for login redirects and modal flows where back should not return to the same page.
Q: How does prefetching behave for dynamic routes?
A: For static routes, Next.js fetches the full RSC payload. For dynamic routes, prefetch loads only up to the nearest loading.tsx boundary, so the shell renders immediately and data loads after navigation.
Q: What is the difference between useRouter from next/navigation and next/router?
A: next/navigation is for the App Router (Next.js 13+) and does not have router.query. next/router is for the Pages Router. Mixing them up compiles fine but breaks at runtime.
Q: How do you handle loading states during programmatic navigation?
A: The App Router shows loading.tsx automatically on navigation. For finer control, wrap router.push() in useTransition and use the isPending flag to disable buttons or show a spinner while the transition is in progress.
Q: How does Link interact with parallel routes and intercepting routes?
A: Intercepting routes (@folder convention) override href resolution before the prefetch fires. Parallel routes load independently, so a @modal slot stays mounted while the primary route changes. Link navigates only the segment it targets without affecting sibling slots.
Examples
Dashboard Navigation with Active States
// components/DashboardNav.tsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
const navItems = [
{ href: '/dashboard', label: 'Overview' },
{ href: '/dashboard/analytics', label: 'Analytics' },
{ href: '/dashboard/users', label: 'Users', skipPrefetch: true },
]
export default function DashboardNav() {
const pathname = usePathname()
return (
<nav className="flex gap-4">
{navItems.map(({ href, label, skipPrefetch }) => (
<Link
key={href}
href={href}
prefetch={skipPrefetch ? false : undefined}
className={pathname === href ? 'font-bold text-blue-600' : 'text-neutral-600'}
>
{label}
</Link>
))}
</nav>
)
}
// Active item gets bold; /users skips prefetch since it is rarely visitedThe active state check is a strict equality on the full pathname. For nested routes like /dashboard/analytics/reports, you would use pathname.startsWith(href) instead.
Login Form with Programmatic Navigation
// app/login/page.tsx
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
export default function LoginPage() {
const router = useRouter()
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setLoading(true)
const formData = new FormData(e.currentTarget)
const result = await login(formData)
if (result.success) {
// replace so the back button skips the login form
router.replace('/dashboard')
} else {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" />
<input name="password" type="password" />
<button disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
)
}router.replace here prevents the login page from appearing in history after a successful login. Without it, the back button from the dashboard would send the user back to the login form.
Server-Side Auth Guard
// app/dashboard/page.tsx
import { redirect } from 'next/navigation'
import { getUser } from '@/lib/auth'
import { Dashboard } from '@/components/Dashboard'
export default async function DashboardPage() {
const user = await getUser()
// Runs on the server, zero client JS involved
if (!user) redirect('/login')
if (user.plan === 'free') redirect('/upgrade')
return <Dashboard user={user} />
}
// redirect() throws internally so Next.js handles the response
// No explicit return needed after redirect callsThis pattern is the standard auth guard in App Router. The check happens before the component renders, the client never receives unauthorized HTML, and no 'use client' directive is needed.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.