Navigation primitives
Moving between routes in the Next.js App Router with Link, useRouter, and the redirect functions.
Your app has routes like /dashboard, /dashboard/invoices, and /invoices/[id], but no way to move between them yet. Four navigations make the gap concrete. A user opening an invoice from a list has to land on its page. A visitor with no session who hits /dashboard has to be bounced to /sign-in. An invoice deleted yesterday has to render “not found” instead of crashing. A marketing page renamed from /pricing-2024 to /pricing has to forward the old URL permanently, so Google updates its index.
The App Router gives you a tool for each:
<Link>for when a user clicks something and you swap to a new page in-app.useRouter().pushfor when your own code decides where to go, from inside an event handler.redirect(),permanentRedirect(), andnotFound()for server code that has to stop what it’s rendering and send the request elsewhere.
One question picks between them: who triggers the navigation, and where does the code run? A person clicking a destination, your code branching inside a handler, and server code rerouting mid-render are three different answers, and the four tools follow from them.
You already know the starting point. A bare <a href="/dashboard"> causes a full page load: the browser tears down the current page and fetches a fresh document. Everything in this lesson is the framework-aware upgrade to that.
Link: client-side navigation
Section titled “Link: client-side navigation”Reach for <Link> from next/link whenever a click takes the user to another page inside your app. Since next/* packages count as external imports, it sits in the first import group:
import Link from 'next/link';Start with what <Link> actually puts on the page. This code:
<Link href="/dashboard">Dashboard</Link>// renders → <a href="/dashboard">Dashboard</a>…renders a real <a href="/dashboard">: an actual anchor, not a <div> with an onClick. Because it is an anchor, every browser affordance a user expects from a link works for free: right-click “open in new tab”, middle-click, “copy link address”, keyboard focus and Enter, the URL in the status bar on hover. A clickable <div> would force you to rebuild every one of those by hand. (The old form that nested a child <a> inside <Link> is gone; since Next 13, <Link> renders the anchor itself.)
So what does <Link> buy you over typing <a> yourself? A contract layered on the anchor. On a plain left-click of a same-origin link, the framework intercepts the click, fetches just the new page’s content, and swaps it in while the shell stays put. This is a soft navigation , a client-side page swap with no full reload, and it’s the difference between an app that feels native and one that flashes blank on every click.
Here’s the same link three ways: as a <Link>, as a bare <a> to the same in-app route, and as an <a> used the way it’s meant to be:
<Link href="/dashboard/invoices">Invoices</Link>Intercepted and swapped in place, so the shell and sidebar stay mounted. Because the dashboard layout never unmounts, a Client Component inside it keeps its state across the navigation: an open menu stays open, the sidebar’s scroll position holds. This is the layout/page boundary from the Layouts & route groups lesson at work, where the layout persists and only the page swaps. No blank flash, no re-downloading the shell.
<a href="/dashboard/invoices">Invoices</a>A full document reload. The browser throws away the current page and rebuilds from scratch: every script re-parses, every layout remounts, all in-memory client state is gone. For a link inside your own app that’s pure waste, and it’s the cost <Link> exists to remove.
<a href="https://stripe.com" target="_blank" rel="noreferrer"> Stripe</a>An external link, where <a> is exactly right. <Link> can’t soft-navigate to another origin; it would render the same anchor and waste effort prefetching a page it has no control over. For anything off-site (https://…, mailto:), reach for a plain <a>.
That gives you the one decision <Link> asks of you: <Link> for routes inside your app, a bare <a> for external URLs. In-app, you get the soft swap and the prefetch you’re about to learn. Off-site, there’s nothing in your route tree to swap or prefetch, so a plain anchor is the correct element.
Building hrefs from data
Section titled “Building hrefs from data”Most links in a real app aren’t static strings; they’re built from data. Here a list of invoices links each row to its detail page at /invoices/[id]. Since href is just a string, build it with a template literal from the id you already have:
<ul> {invoices.map((invoice) => ( <li key={invoice.id}> <Link href={`/invoices/${invoice.id}`}>{invoice.number}</Link> </li> ))}</ul>Two pieces here are revision. The href is the /invoices/[id] shape from the Dynamic segments lesson with the slot filled in: [id] is the route’s placeholder, ${invoice.id} is the value going into it. And each <li> carries a key tied to the invoice’s id, the stable key every list rendered from data needs so React can track rows across renders.
The replace and scroll props
Section titled “The replace and scroll props”<Link> takes a handful of props; two come up often enough to know now. Both have sensible defaults, so you reach for them only when the default is wrong.
replace controls the browser’s history. By default a click pushes a new entry onto the history stack, so the back button returns the user where they came from. Passing replace swaps the current entry instead, dropping it from history. Reach for it when landing back on the current URL would be a mistake, such as a step in a flow that immediately forwards somewhere else, where a back-button stop would just bounce the user forward again.
scroll controls scroll position after navigation. It defaults to true, which does not mean “always jump to the top”: Next keeps your scroll position when the new page is still in view and scrolls to the top only when it isn’t. scroll={false} opts out of even that top-scroll.
Prefetching: loading the page before the click
Section titled “Prefetching: loading the page before the click”A soft navigation still has to fetch the new page’s content, and a fetch takes time. A <Link> click lands with no spinner because the framework usually fetched the destination before you clicked, so the content was already in memory when the click happened.
Trust the default, and resist the urge to tune prefetching link by link. The default is the right amount of prefetching for almost every link you’ll write, and the most common mistake is reaching for the prefetch prop to “make it faster” when the default was already optimal.
With prefetch unset, prefetching fires when the <Link> scrolls into the viewport, on mount or as the user scrolls, not on hover. The framework then fetches the destination in the background. For a static destination it prefetches the whole route and its data; for one that depends on per-request data it prefetches only the partial route down to the nearest loading.tsx boundary, which you’ll meet in the next chapter. If the user then hovers and the prefetched data has gone stale, Next refreshes it.
Scrub through the three moments around a click to see what the framework is doing:
To override the default, the prefetch prop has exactly three values:
The table also flags what to watch: don’t reach for prefetch={true} reflexively. It forces the full route on every link, so a screen full of such links sends a wave of requests to your backend for pages the user will never open, all for a marginal gain.
Navigating from code with useRouter
Section titled “Navigating from code with useRouter”<Link> covers the case where the user clicks a thing and that thing is the destination. But sometimes the navigation isn’t a link the user clicks; it’s a decision your code makes. The user submits a “create invoice” form, the action succeeds, and you send them to the new invoice’s page, a URL that didn’t exist until the action returned its id. For that, you reach for useRouter.
Start with the import, because the single most common bug with this hook lives there. useRouter comes from next/navigation:
import { useRouter } from 'next/navigation';It is not next/router, the Pages Router hook from an older Next.js this course never uses. The two look nearly identical and half the tutorials online still show the old one, so when something goes wrong with useRouter, check this import first.
useRouter is a hook, and hooks only run in Client Components, so the file calling it needs 'use client' at the top. A Server Component has no useRouter; when server code needs to reroute it uses redirect(), the subject of the next section.
Here’s the create-invoice button, built up one piece at a time:
'use client';
import { useRouter } from 'next/navigation';
export const CreateInvoiceButton = () => { const router = useRouter();
const handleClick = async () => { // TODO(Ch 043) — replace with the real createInvoice Server Action const newId = await createInvoice(); router.push(`/invoices/${newId}`); };
return <button onClick={handleClick}>Create invoice</button>;};useRouter is a hook, so this file is a Client Component with 'use client' at the top, and the import is from next/navigation, not next/router. Both are required for the hook to work.
'use client';
import { useRouter } from 'next/navigation';
export const CreateInvoiceButton = () => { const router = useRouter();
const handleClick = async () => { // TODO(Ch 043) — replace with the real createInvoice Server Action const newId = await createInvoice(); router.push(`/invoices/${newId}`); };
return <button onClick={handleClick}>Create invoice</button>;};Call the hook at the top level of the component, like every hook, and hold onto the router object it returns. You’ll call methods on it from your handlers.
'use client';
import { useRouter } from 'next/navigation';
export const CreateInvoiceButton = () => { const router = useRouter();
const handleClick = async () => { // TODO(Ch 043) — replace with the real createInvoice Server Action const newId = await createInvoice(); router.push(`/invoices/${newId}`); };
return <button onClick={handleClick}>Create invoice</button>;};Inside the click handler, you do the work, then navigate. createInvoice() is a stub; wiring it to a real Server Action comes in a later chapter, which is what TODO(Ch 043) marks. Treat it as async work that returns the new id. Once it resolves, router.push performs the same soft navigation a <Link> would, triggered by your code, and the user lands on the brand-new invoice.
The router object has a few methods. Two you’ll use constantly, the rest you should recognize:
push(href)navigates tohrefand adds a history entry. This is the everyday one: the same soft navigation as<Link>, with your code as the trigger.replace(href)navigates but replaces the current history entry instead of adding one, the imperative twin of<Link replace>, for when a back-button stop on the current URL would be wrong.back()andforward()walk the browser history, exactly like the browser’s own buttons.refresh()re-fetches the current route’s server data and re-renders it without throwing away client state. Its real job, refreshing the screen after a mutation changes the data behind it, comes in a later chapter; for now, just know it exists.
One distinction here sets up the next section. useRouter().push is an imperative tool: you call it, at the moment you decide to, from inside a handler, the opposite of declarative <Link>, which you place in your JSX for the user’s click to run. That imperative nature comes with a hard constraint: useRouter works from event handlers, on click, on submit, after async work resolves. The throwing functions in the next section run during render and will not work from inside an event handler. So the split is clean: navigating in response to a handler is useRouter’s job, stopping and rerouting while the page still renders is the trio’s job.
One last boundary, since the overlap with <Link> confuses people. router.push and <Link> produce the identical soft navigation: same swap, same prefetch-warmed result. The only difference is the trigger, code versus click. So default to <Link> whenever the navigation maps to a thing the user clicks, since it ships you a real, accessible anchor for free. Reach for push only when there’s genuinely no anchor to click.
The throwing trio: redirect, permanentRedirect, and notFound
Section titled “The throwing trio: redirect, permanentRedirect, and notFound”next/navigation gives you three more functions: redirect, permanentRedirect, and notFound. They work differently from everything so far. <Link> and useRouter move the user; these three interrupt the server mid-render and reroute the request. They run during render: in Server Components, in Client Components as they render (not in their event handlers), in route handlers, and in Server Actions.
One idea makes all three click:
They don’t return. They throw. Each one throws a special signal the framework is waiting to catch. The moment you call it, control leaves your function immediately, just like a regular throw, and nothing after it runs. The framework catches the signal and does the rerouting. There’s no value to inspect, because the function never returns to you. TypeScript says so outright: the return type is never , the type of a function that cannot finish normally.
That one fact clears up the two bugs people hit most often.
The first: writing const result = redirect('/x') and trying to use result. There is no result. redirect doesn’t hand you a value, it leaves, so the assignment never completes.
The second is harder to spot: wrapping these calls in try/catch. It looks reasonable around async work, but a broad catch swallows the framework’s signal exactly like any other thrown value, so the redirect or 404 silently dies and the page renders on as if you never called it. This is the most common bug with the trio, and the rule that prevents it is firm: never wrap these calls in try/catch. If you have cleanup to do, do it before the call, and let the signal throw clean past your code to the framework.
Here’s that bug and its fix, side by side, on a real dynamic page:
export default async function InvoicePage({ params }: PageProps<'/invoices/[id]'>) { const { id } = await params;
try { const invoice = await getInvoice(id); if (!invoice) notFound(); return <InvoiceDetail invoice={invoice} />; } catch (error) { console.error(error); return <p>Something went wrong.</p>; }}The catch swallows notFound()’s signal. A broad catch catches everything, framework signals included, so the user gets “Something went wrong.” instead of a 404, and the logged error is really your own notFound() signal. The bug is easy to miss because the code looks correct.
export default async function InvoicePage({ params }: PageProps<'/invoices/[id]'>) { const { id } = await params;
const invoice = await getInvoice(id); if (!invoice) notFound();
return <InvoiceDetail invoice={invoice} />;}notFound() throws straight past your code to the framework, which renders the not-found UI and serves a real 404. And because it never returns on a miss, TypeScript knows invoice is non-null below it: no return, no else, since the throw already handled the missing case.
That second tab also answers a question left open in the Dynamic segments lesson: notFound() needs no return because it throws, so the code below it is unreachable on a miss and the type narrows.
The three functions are now quick to tell apart: they all throw, and differ only in what they tell the browser.
redirect(path): the temporary one (307)
Section titled “redirect(path): the temporary one (307)”This is the everyday redirect. It sends the browser to a new URL with HTTP status 307 (Temporary Redirect), which preserves the request method. Two cases dominate.
The first is the auth gate. A server component or layout checks for a session; if there’s none, it redirects to the sign-in page before rendering anything protected:
export default async function DashboardPage() { // TODO(Unit 8) — replace with the real session lookup const session = await getSession(); if (!session) redirect('/sign-in');
return <Dashboard userId={session.userId} />;}getSession() is a stub, since real authentication is a later unit, but the shape is the real pattern. The same narrowing you saw with notFound() applies: after if (!session) redirect('/sign-in'), TypeScript knows session is non-null, because the no-session branch already left the function.
The second is the post-action redirect: after a Server Action mutates something, you send the user to the result. That’s a later chapter, but one detail is worth filing away. Inside a Server Action, redirect() issues 303 (See Other), not 307. The reason is mechanical: a Server Action arrives as a POST, and 303 tells the browser to follow with a GET to the new page rather than re-POST to it. redirect picks the right status for the context, so expect a 303 in the Network tab after an action.
permanentRedirect(path): the permanent one (308)
Section titled “permanentRedirect(path): the permanent one (308)”Same throwing behavior, different message to the world. permanentRedirect sends status 308 (Permanent Redirect), telling search engines and browser caches that the old URL is gone for good. Use it for real URL migrations, such as renaming /pricing-2024 to /pricing, where you want Google to drop the old URL and move its authority to the new one.
The choice is lopsided: redirect is the overwhelming default. permanentRedirect is the rare, deliberate one, because “permanent” is a promise to caches you can’t easily take back. Reach for it only when the move is genuinely forever and SEO is the reason; if the old URL might return, use a temporary redirect. (It also issues 303 inside a Server Action, for the same POST-then-GET reason.)
notFound(): the missing-resource one (404)
Section titled “notFound(): the missing-resource one (404)”You’ve already half-met this one. notFound() throws a signal that makes the framework render the closest not-found UI and serve a real 404 status. Every dynamic route is a candidate: you validate the params, query the row, and if it comes back null, the id pointed at an invoice that never existed or was deleted, so you call notFound() and let it throw. The not-found page itself is wired up by a not-found.tsx file, which you’ll add in the next chapter.
You can also let the type system show you the throwing model directly. Hover the calls in this snippet:
const session = await getSession();if (!session) redirect('/sign-in');
const invoice = await getInvoice(id);if (!invoice) notFound();The trio’s watch-outs in one place: these are tools for server flow control, not functions whose result you use. They throw rather than return, so never try/catch them, because a broad catch eats the signal. redirect is your 307 default (303 inside a Server Action), permanentRedirect is the rare permanent 308, and notFound is the 404 that pairs with a not-found.tsx. All three end the current render the instant you call them.
Choosing the right primitive
Section titled “Choosing the right primitive”Four tools, one decision: ask who triggers the navigation, then ask where the code runs. Walk the tree:
A user clicking an in-app destination. You get a soft navigation, free prefetch, and a real accessible anchor, which makes this the default for anything a user points at inside your app.
Off-site, so there’s nothing to soft-navigate or prefetch. A bare <a> is the right element; <Link> would only add dead weight trying to prefetch a page it can’t control.
Your code decides the destination from inside a handler, after a submit or after async work resolves. Client Component only ('use client'). The same soft navigation as <Link>, just triggered by code instead of a click.
Server code that must stop and send the request elsewhere, with the auth gate as the canonical case. It throws, so nothing after it runs. (303 inside a Server Action.)
The id pointed at a row that isn’t there, whether deleted or never created. Throws to the closest not-found.tsx and serves a real 404.
The URL moved for good and you want search engines to drop the old one. The rare, deliberate permanent case; redirect is the default everywhere else.
For each scenario below, pick the primitive you’d reach for:
Pick the primitive each situation calls for. Pick the right option from each dropdown, then press Check.
A user clicks a row in the invoice list to open /invoices/42: . After createInvoice resolves inside a click handler, you send the user to the new invoice: . A signed-out visitor lands on /dashboard and must be bounced to sign-in: . Someone opens /invoices/99, but invoice 99 was deleted last week: . Your old /pricing-2024 page should forward to /pricing so Google updates its index: . A footer link points to the Stripe documentation: .
One last check: what happens to the code after a redirect().
A Server Component runs if (!session) redirect('/sign-in'), and the very next line is return <Dashboard userId={session.userId} />. On a request with no session, what happens to that return line?
redirect call hands control straight to the framework, so execution never gets back to the next line.session is null — which is why you must write return redirect('/sign-in') instead.redirect() is typed never: it throws a signal the framework catches, the same way throw exits a function on the spot. Nothing below it in that scope is reachable on the no-session path, so the return simply never happens — there’s no “after” to run, and nothing to return. That same unreachability is why TypeScript narrows session to non-null below the if, so reaching the return at all means a session exists.The next lesson keeps building on the URL surface, letting a single screen render two independent routes at once.