Linking and Navigation
The idea in one sentence
Section titled “The idea in one sentence”<Link> handles most navigation with client-side transitions and automatic prefetching, while useRouter and the next/navigation helpers cover programmatic and server-side navigation.
Link and its prefetch modes
Section titled “Link and its prefetch modes”<Link> navigates on the client without a full page reload. It extends the native <a> element, so it also works for a plain external URL — but prefetching and client-side transitions only apply to routes inside your own app.
import Link from 'next/link'
export default function Nav() { return ( <nav> <Link href="/">Home</Link> <Link href="/blog/42">Post 42</Link> <Link href="/dashboard" prefetch={false}>Dashboard</Link> <Link href="https://nextjs.org">External site (plain anchor behavior)</Link> </nav> )}As of Next.js 16.3.6, the App Router’s prefetch prop accepts exactly three values (verified against the bundled docs for this version):
| Value | Behavior |
|---|---|
"auto" or null (default) | Static routes prefetch in full; dynamic routes prefetch only down to the nearest loading.js boundary |
true | The full route prefetches regardless of static or dynamic — with Partial Prefetching enabled, this includes the App Shell and cached URL-dependent content |
false | Prefetching never happens — not on entering the viewport, and not on hover either |
Prefetched routes land in the Client Cache, so a navigation may be served instantly from a cached RSC payload instead of a fresh request. The Client Cache is covered fully in the Data Fetching module.
Programmatic navigation with useRouter
Section titled “Programmatic navigation with useRouter”For navigation triggered by logic (after a form submit, a timeout, an auth check) use useRouter from next/navigation in a Client Component.
'use client'
import { useRouter } from 'next/navigation'
export default function SaveButton() { const router = useRouter() async function onSave() { await fetch('/api/save', { method: 'POST' }) router.push('/dashboard') // or replace, back, refresh } return <button onClick={onSave}>Save</button>}router.push adds a history entry, router.replace swaps the current one, router.back goes back, and router.refresh re-fetches data and re-renders Server Components for the current route — it clears the Client Cache for that route, but does not invalidate any server-side cache (revalidatePath/revalidateTag do that).
Reading the current route
Section titled “Reading the current route”Client Components read navigation state with hooks from next/navigation: usePathname, useSearchParams, and useParams.
'use client'
import { usePathname, useSearchParams, useParams } from 'next/navigation'
export default function Debug() { const pathname = usePathname() // "/blog/42" const searchParams = useSearchParams() // read ?sort=new const params = useParams() // { id: "42" } return <pre>{pathname} {searchParams.get('sort')} {params.id}</pre>}Server-side redirect and notFound — and why try/catch swallows them
Section titled “Server-side redirect and notFound — and why try/catch swallows them”redirect() and permanentRedirect() (both from next/navigation) work by throwing. The thrown error’s digest string encodes the destination and status right in the text, which is how Next.js recognizes it further up the call stack and turns it into a real HTTP redirect instead of a crash. Wrapping the call in your own try/catch intercepts that throw before Next.js ever sees it — the redirect is silently discarded.
This is the same failure shape the mutations module proves for notFound() inside a Server Action (see server-actions-and-mutations’s mutations-and-revalidation lesson, which also covers unstable_rethrow); here is the equivalent bug on the routing side, in a Route Handler doing an auth check:
import { redirect } from 'next/navigation'
// BUG: try/catch swallows redirect()export async function GET(request: Request) { const { searchParams } = new URL(request.url) try { const authed = searchParams.get('authed') === 'true' if (!authed) redirect('/login') return Response.json({ ok: true }) } catch (err) { console.error('redirect-bad caught this instead of letting it redirect:', err) return Response.json({ ok: false, error: 'caught, no real redirect happened' }) }}Built, started, and requested for real (?authed=false, so the redirect should fire):
GET /api/redirect-bad?authed=false -> HTTP 200{"ok":false,"error":"caught, no real redirect happened"}
server log:redirect-bad caught this instead of letting it redirect: Error: NEXT_REDIRECT digest: 'NEXT_REDIRECT;replace;/login;307;'The catch block ran, logged the exact error redirect() throws, and returned a normal 200 JSON response instead of ever redirecting — the digest NEXT_REDIRECT;replace;/login;307; shows Next.js had already computed the destination, the navigation type (replace, the default outside Server Actions), and the 307 status, and none of it escaped the catch. The fix is simply to keep redirect() outside any try/catch that doesn’t explicitly rethrow it:
import { redirect } from 'next/navigation'
export async function GET(request: Request) { const { searchParams } = new URL(request.url) const authed = searchParams.get('authed') === 'true' if (!authed) redirect('/login') return Response.json({ ok: true })}GET /api/redirect-good?authed=false -> HTTP 307redirect() can also be called during render in a Client Component (but not inside an event handler — use useRouter there instead), and it accepts absolute URLs for redirecting to an external site.
graph TD N["Need to navigate"] --> UI["From a link or UI?"] N --> LOGIC["From logic in the client?"] N --> SERVER["From the server, or during render?"] UI --> L["Link (prefetch + client nav)"] LOGIC --> R["useRouter push/replace/back/refresh"] SERVER --> S["redirect() / notFound() (throws NEXT_REDIRECT / NEXT_HTTP_ERROR_FALLBACK)"] S -->|"wrapped in try/catch?"| SWALLOW["Silently swallowed unless rethrown"]
Mental Model
Section titled “Mental Model”redirect() and permanentRedirect() are not “return a response” functions — they are exceptions with a purpose-built digest string that Next.js’s own machinery recognizes as it unwinds the stack. Anywhere you would worry about an unhandled exception crashing a request, you should equally worry about a try/catch accidentally catching one of these before it reaches Next.js.
Trade-offs
Section titled “Trade-offs”| Tool | Runs where | Effect |
|---|---|---|
<Link prefetch="auto"|null> (default) | Client | Prefetches static routes fully, dynamic routes down to loading.js |
<Link prefetch={true}> | Client | Always prefetches the full route |
<Link prefetch={false}> | Client | Never prefetches — not on viewport, not on hover |
useRouter().push/replace | Client, inside an event handler | Client-side navigation, adds/swaps a history entry |
redirect() / permanentRedirect() | Server, or during Client Component render | Throws NEXT_REDIRECT; swallowed by an unguarded try/catch |
Common Misconceptions
Section titled “Common Misconceptions”- “A try/catch around code that might redirect is always safer.” Proven above: it silently discards a real 307/308 redirect and returns 200 instead — exactly like the
notFound()case the mutations module covers. - “
prefetch={false}still prefetches on hover, like older docs describe.” In the App Router as of 16.3.6,falsemeans never — no viewport prefetch and no hover prefetch. - “
redirect()works the same inside a Client Component event handler as during render.” It doesn’t — inside an event handler you needuseRouter().push/replaceinstead.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet does navigation, check for these before trusting it:
- Is
redirect()orpermanentRedirect()called inside atry/catchthat doesn’t rethrow? That swallows the navigation — proven above with a real 200 instead of a 307. - Is
redirect()called from inside a Client Component event handler (anonClick, not render)? That’s not supported there — it needsuseRouter().push/replace. - Does an internal navigation use a plain
<a href="/...">instead of<Link>? It still works, but loses client-side transitions and prefetching for no reason. - Is
prefetch={false}used somewhere that actually wants hover-prefetch? In the App Router that value disables prefetching entirely, not just on-viewport. - Does the code assume
router.refresh()invalidates server-side cached data? It only clears the Client Cache for the current route — userevalidatePath/revalidateTagfor the server side.
🐛 Spot the bug
import { redirect } from 'next/navigation'
export async function POST(request: Request) {try { const order = await createOrder(request) if (order.requiresPayment) { redirect('/pay/' + order.id) } return Response.json({ ok: true, order })} catch (err) { console.error('checkout failed', err) return Response.json({ ok: false, error: 'checkout failed' }, { status: 500 })}}Show the answer
redirect() throws a NEXT_REDIRECT-digest error so Next.js can turn it into a real HTTP redirect further up the stack. Here it’s called inside a try block whose catch logs any error and returns a 500 JSON body — including the one redirect() just threw. Proven with the same pattern built and requested for real: the response comes back 200/500 with a JSON body instead of a 307 redirect to /pay/[id]. The fix is to call redirect() after the try/catch (do the write inside the guarded block, decide and redirect outside it), or to rethrow NEXT_REDIRECT-shaped errors before your own error handling runs.