Skip to content
Chapter 29Lesson 4

Navigation primitives

How users and your code move between routes in a Next.js App Router app, using Link, useRouter, and the redirect functions.

You have a URL surface now. The last three lessons turned folders into segments, gave each route a page.tsx leaf and a layout.tsx shell, and let [id] stand in for an unbounded set of URLs. So your app already has /dashboard, /dashboard/invoices, and /invoices/[id]. What it can’t do yet is move between them. A user who opens an invoice from a list has to get there. A visitor with no session who lands on /dashboard has to be bounced to /sign-in. An invoice that was deleted yesterday has to render “not found” instead of crashing. And a marketing page you renamed from /pricing-2024 to /pricing has to forward the old URL permanently, so Google updates its index.

These are four different jobs, and the App Router gives you four tools for them:

  • <Link> for when a user clicks something and you swap to a new page in-app.
  • useRouter().push for when your own code decides where to go, from inside an event handler.
  • redirect(), permanentRedirect(), and notFound() for server code that has to stop what it’s rendering and send the request somewhere else.

You don’t have to memorize four APIs separately. One question sits underneath all of them, and once you can ask it you’ll know which tool to reach for: who triggers the navigation, and where does the code run? A person clicking a real destination is one situation. Your code branching inside a handler is another. Server code that has to stop mid-render and reroute is a third. Keep that question in mind, and the four tools follow from it.

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.

Section titled “Link: client-side navigation that feels instant”

Start with the one you’ll reach for most. When a user clicks something that takes them to another page inside your app, you use <Link> from next/link. Since next/* packages count as external imports, it sits in the first import group:

import Link from 'next/link';

The first thing to understand about <Link> is what it actually puts on the page, because it’s less magical than the name suggests. This code:

<Link href="/dashboard">Dashboard</Link>
// renders → <a href="/dashboard">Dashboard</a>

…renders a real <a href="/dashboard">: not a <div> with an onClick, not a custom widget, but an actual anchor element. This matters more than it looks. Because it is an anchor, every browser affordance a user expects from a link just works: right-click “open in new tab”, middle-click, “copy link address”, keyboard focus and Enter, the URL in the status bar on hover. You get all of that for free, and you’d have to rebuild every piece of it by hand with a clickable <div>. (If you’ve seen old Next.js tutorials wrap a child <a> inside <Link>, that form is gone. Since Next 13, <Link> renders the anchor itself, and you’ll never write the nested version.)

So if it renders a plain anchor, what does the next/link import buy you over typing <a> yourself? It adds a contract on top of the anchor. When the user does a plain left-click on a same-origin link, the framework steps in: instead of letting the browser do its full-document navigation, it intercepts the click, fetches just the new page’s content, and swaps it into place. The shell stays put. This is called 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.

To see why that matters, put the two side by side. Here’s the same link three ways: as a <Link>, as a bare <a> pointing at the same in-app route, and as an <a> used the way it’s meant to be used:

<Link href="/dashboard/invoices">Invoices</Link>

Intercepted and swapped in place, so the shell and the 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 render boundary from the layouts lesson doing its job, where the layout persists and only the page swaps. No blank flash, no re-downloading the shell.

That third tab states the one decision <Link> asks of you: <Link> for routes inside your app, a bare <a> for external URLs. In-app, <Link> gives you the soft swap and the prefetch you’re about to learn about. Off-site, it gives you nothing: the destination isn’t part of your route tree, so there’s nothing to swap or prefetch, and a plain anchor is the correct element.

Most of the links in a real app aren’t static strings; they’re built from data. You have a list of invoices and each row links to that invoice’s detail page at /invoices/[id]. The href is just a string, so you 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 things to notice, both of which you’ve met before. The href is the same /invoices/[id] shape from the dynamic-segments lesson, now filled in with a concrete id: [id] is the slot in the route tree, and ${invoice.id} is the value going into it. The <li> carries a key tied to the invoice’s identity, because every list rendered from data needs a stable key so React can track rows across renders. Nothing here is new; it’s the two ideas meeting.

<Link> takes a handful of props, but two come up often enough to introduce now. Both have sensible defaults, so you reach for them only when the default is wrong.

replace changes what happens to the browser’s history. By default, clicking a <Link> pushes a new entry onto the history stack, so the back button returns to where the user came from, which is almost always what you want. Passing replace instead swaps the current entry, so the page the user was on vanishes from history. Reach for it when landing back on the current URL would be a mistake. A step in a flow that immediately forwards somewhere else is the usual example: a back-button stop on that intermediate URL would just bounce the user forward again.

scroll controls scroll position after navigation, and the common belief about it is wrong. It defaults to true, and people assume that means “always jump to the top.” It doesn’t. With the default, 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. You’ll go a long time before you touch this, but if you ever see a navigation fail to jump to the top and wonder why, this is the behavior you’re looking at, working as intended.

Prefetching: the page is loaded before the click

Section titled “Prefetching: the page is loaded before the click”

“Feels instant” isn’t just the soft swap. A soft navigation still has to fetch the new page’s content, and a fetch takes time. The reason a <Link> click lands with no spinner is that the framework usually fetched the destination before you clicked, prefetching it. The content was already sitting in memory when the click happened, so the swap is immediate.

The instinct to fight here is the urge to tune prefetching link by link. The default behavior is already the right amount of prefetching for almost every link you’ll ever write, and the most common mistake is reaching for the prop to “make it faster” when the default was already optimal. Learn what the default does first, and you’ll rarely need the prop at all.

With prefetch left unset, here’s what happens. Prefetching fires when the <Link> scrolls into the viewport, that is, when it becomes visible, whether on initial mount or as the user scrolls down. It does not fire on hover. The framework quietly fetches the destination in the background. For a static destination, it prefetches the whole route and its data. For a destination that depends on per-request data, it prefetches only the partial route down to the nearest loading boundary. (That boundary is a loading.tsx file, which you’ll meet in the next chapter; it marks the part of the page that can show instantly while the rest streams in.) Then, if the user hovers or pauses on the link and the prefetched data has gone stale in the meantime, Next quietly refreshes it. By the time the click comes, the page is warm.

The following sequence makes that invisible timeline visible. Scrub through it to see what the framework is doing in the three moments around a click:

app.example.com /dashboard
Navigation
Dashboard
Invoices prefetching…
Settings
fetched in the background — no click yet
The link becomes visible. The framework starts fetching its route and data in the background — no interaction needed, the user has not touched anything yet, and the URL has not changed.
app.example.com /dashboard
Navigation
Dashboard
Invoices warm
Settings
already warm · stale data refreshed
The destination is already warm. A hover or a pause is enough; if the prefetched data went stale while it sat there, Next refreshes it now — still before any click, still on /dashboard.
app.example.com /dashboard /invoices
Invoices /dashboard/invoices
INV-041 $1,200
INV-042 $3,480
INV-043 $760
instant · no spinner
The click lands and the swap is instant — the bundle and data were already here, so there is no loading spinner. Only now does the URL become /dashboard/invoices.

If you do need to override the default, the prefetch prop has exactly three values:

Value
What it does
When to reach for it
"auto" default
Prefetch on viewport, partial route for dynamic destinations, refresh stale data on hover.
Almost always. This is the senior default — leave the prop unset.
true
Always prefetch the full route, static or dynamic, ignoring the partial-route optimization.
A small set of high-intent links to a destination you know is expensive to render and want fully warm before the click.
false
Never prefetch — not on viewport, not on hover.
When prefetching costs more than it saves: many links to heavy or rate-limited endpoints, or a link the user is unlikely to click.

The table also points at the main thing to watch: don’t reach for prefetch={true} reflexively. Forcing the full route on every link multiplies your prefetch traffic for a marginal gain. If a screen has many such links, each one full-prefetching as it enters the viewport, the result is a wave of requests to your own backend for pages the user will never open. The default already made the smart trade-off, so override it only when you have a specific reason to.

<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 now you need to send them to the new invoice’s page. There was never a link to that page on screen, and the destination didn’t even 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. That singular next/router is the Pages Router’s hook, from an older era of Next.js that this course never uses. The two imports look nearly identical, half the tutorials on the web still show the old one, and importing the wrong one gives you a hook that doesn’t behave the way anything in this chapter expects. 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, the module-boundary directive from the routing model. This is the natural limit of useRouter: it exists for the client. A Server Component has no useRouter at all, because it never renders on the client, so there’s no client-side router for it to reach. When server code needs to reroute, it uses redirect() instead, which is 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, which is why 'use client' sits 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 here; wiring it to a real Server Action comes in a later chapter, which is what TODO(Ch 043) marks. For now, treat it as some async work that returns the new id. Once it resolves, router.push performs the same soft navigation a <Link> would, just triggered by your code. The user lands on the brand-new invoice.

1 / 1

The router object has a few methods. Two you’ll use constantly, the rest you should be able to recognize:

  • push(href) navigates to href and 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. It’s the imperative twin of <Link replace>, for the same reason: when a back-button stop on the current URL would be wrong.
  • back() and forward() 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. You’ll meet its real job, refreshing the screen after a mutation changes the data behind it, 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. That’s the opposite of <Link>, which is declarative, because you place it in your JSX and the user’s click runs it. That imperative nature comes with a hard constraint. useRouter works from event handlers: on click, on submit, after some async work resolves. The throwing functions in the next section work the other way around. They run during render, and they will not work from inside an event handler. So the split is clean: navigating in response to a handler is useRouter’s job, and stopping and rerouting while the page is still rendering 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 the default is <Link> whenever the navigation maps to a thing the user clicks, because it ships you a real, accessible anchor for free. Reach for push only when there’s genuinely no anchor to click, when the navigation follows from code rather than from a destination the user pointed at.

The throwing trio: redirect, permanentRedirect, and notFound

Section titled “The throwing trio: redirect, permanentRedirect, and notFound”

This part of the lesson trips up a lot of people, so it’s worth slowing down. There are three more navigation functions, redirect, permanentRedirect, and notFound, all from next/navigation, and they work differently from everything so far. <Link> and useRouter move the user. These three interrupt the server while it’s working 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, and missing it is why people get stuck:

They don’t return. They throw. Each of these functions throws a special signal that the framework is waiting to catch. The moment you call one, control leaves your function immediately, just like a regular throw, and nothing after it in the same scope runs. The framework catches the signal and does the rerouting. You don’t get a value back to inspect; there’s nothing to inspect, because the function never returns to you. TypeScript states this outright: their return type is never , the type of a function that cannot finish normally.

That single fact clears up the two bugs people hit most often with these functions.

The first: people write const result = redirect('/x') and try to do something with result. There is no result. redirect doesn’t hand you a value; it leaves. Assigning its “return” is meaningless, because the assignment line never completes.

The second one is harder to spot: people wrap these calls in try/catch. It seems reasonable, since you’re doing async work, you catch errors around it, and the notFound() sits inside the try. But a broad catch swallows the framework’s signal exactly like it swallows any other thrown value, so the redirect or the 404 silently dies. 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 you call. Let the signal throw clean past your code to the framework that’s waiting for it.

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. notFound() throws, and the catch right there catches it, because a broad catch catches everything, framework signals included. So instead of a 404 the user gets “Something went wrong.”, and the logged error is really your own notFound() signal. The 404 never fires, and the cause is easy to miss because the code looks correct.

That second tab also closes a loose end from the dynamic-segments lesson. There you validated params and queried the row, and notFound() was named as the thing to call when the row came back null, but the reason you didn’t have to return it was left hanging. Now you can see it: notFound() doesn’t return because it can’t. It throws. The code below it is unreachable on a miss, so the type narrows and the control flow just works.

With the shared model in hand, the three functions are quick. They all throw; they differ only in what they tell the browser.

This is the everyday redirect. It sends the browser to a new URL with HTTP status 307 (Temporary Redirect), which preserves the request method. You’ll reach for it constantly, and two cases dominate.

The first is the auth gate, the pattern you’ll lean on across the whole app. A server component or layout checks for a session; if there’s no session, 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 here, because real authentication is a later unit, and the TODO(Unit 8) marks it as a placeholder. But the shape is the real pattern, and the same narrowing you saw with notFound() applies. After if (!session) redirect('/sign-in'), TypeScript knows session is non-null, because the redirect threw away the no-session path. If there’s no session, the function already left.

The second case 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 now, because it surprises people. Inside a Server Action, redirect() does not issue 307. It issues 303 (See Other). The reason is mechanical: a Server Action arrives as a POST, and after it you want the browser to follow with a GET to the new page, not re-POST to it. 303 is the status that says “go here, and use GET.” You don’t have to do anything to get this, since redirect picks the right status for the context. Just don’t be surprised when you see 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), which tells search engines and browser caches that the old URL is gone for good and they should update their records. Use it for real URL migrations, such as renaming /pricing-2024 to /pricing or rebranding a marketing route, where you actively want Google to drop the old URL from its index and point all its authority at the new one.

The choice between the two is lopsided: you’ll reach for redirect the overwhelming majority of the time. permanentRedirect is the rare, deliberate choice, because “permanent” is a promise to caches you can’t easily take back. If there’s any chance the old URL comes back, use a temporary redirect. Reach for permanentRedirect only when the move is genuinely forever and SEO is the reason. (Like 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 for it. 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. That’s the if (!invoice) notFound() you saw above. The “render the not-found page” half is wired up by a not-found.tsx file, which you’ll add in the next chapter. For now, calling notFound() is the signal, and the framework supplies the page.

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

To gather 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.

Four tools, one question. The lesson comes down to a single decision: ask who triggers the navigation first, then ask where the code runs. Get the order right and the tool is never in doubt. Walk the tree:

Which navigation primitive?

Now check that the decision sticks. 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: .

And one last check on the idea most worth holding onto: what actually 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?

It’s never reached — the redirect call hands control straight to the framework, so execution never gets back to the next line.
It runs, but the framework discards the JSX it returns once the redirect resolves.
It runs as a fallback, rendering the dashboard only if the redirect fails.
It runs and crashes, because session is null — which is why you must write return redirect('/sign-in') instead.

You now have the whole navigation surface: <Link> for the clicks, useRouter for the code-driven moves, the throwing trio for the server-side reroutes, and the one question that picks between them every time. The next lesson keeps building on the URL surface, letting a single screen render two independent routes at once.