Skip to main content

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

  • Link is a smart <a> tag: same HTML output, no page reload, automatic prefetch on viewport entry or hover
  • useRouter().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' needed
  • useRouter from next/navigation (App Router) and next/router (Pages Router) are different hooks with different APIs
  • Default Link prefetches on viewport entry; use prefetch={false} for rarely visited pages

Quick Example

tsx
// 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 reload

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

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): replace prop on Link or router.replace()
  • Rarely visited or deep admin pages: Link with prefetch={false}

Comparison Table

FeatureLinkuseRouter().push()redirect()
Where it runsClient (JSX)Client (event handler)Server (component/action)
PrefetchAutomaticNoN/A
Adds to historyYes, defaultYesN/A
Needs 'use client'NoYesNo
Use caseNav UIPost-logic navigationAuth 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.

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() 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

tsx
'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)} /> }
MethodWhat 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

tsx
// 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} /> }
tsx
// 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

tsx
'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

tsx
// 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

tsx
// 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

tsx
// 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.

tsx
// 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: Link for sidebar navigation between projects and deployments
  • shadcn/ui nav components: Link wrapped with usePathname for active state styling
  • T3 Stack apps: Link for 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

tsx
// 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 visited

The 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

tsx
// 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

tsx
// 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 calls

This 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?