Skip to main content
ASoc
Tutorial

Next.js Navigate to Another Page: Link, push or redirect

Five ways to change pages in the App Router and where each runs. Counted in a real codebase: 26 files use Link, 2 call useRouter, 12 redirect() calls.

The ASoc Team7 min read

To navigate to another page in the Next.js App Router, use <Link href="/path"> for anything the user clicks, router.push() from useRouter after something finishes in a Client Component, and redirect() on the server when a request should never render. Everything else is a special case. This codebase uses all three and nothing more exotic.

The choice is not a style preference. Each mechanism runs in a different place, produces different HTML for crawlers, and fails differently. The counts and code below come from this repository's src/ tree and its next build output (Next.js 16.2.9, Turbopack), not from a demo app.

The five ways, and where each one runs

MechanismRuns inOutputUse it when
<Link href> from next/linkServer or Client ComponentA real <a href> in the HTMLThe user clicks to go somewhere. The default
router.push() / router.replace()Client Component onlyNothing in the HTMLNavigation follows async work (a form succeeded, a tab changed)
redirect() from next/navigationServer Component, Server Action, Route HandlerAn HTTP 307 (or a client navigation in a Server Action)The request should never render this page
permanentRedirect()Same as redirect()HTTP 308A URL has moved for good
window.location or a plain <a>BrowserA full page loadLeaving the app, or you need a hard reload

The first row matters for SEO. A <Link> renders an anchor with a real href, so a crawler sees the link graph. router.push() is invisible to anything that does not execute your JavaScript. The Link component post covers its props, and Next.js redirects covers the status codes.

How often each one appears in this repo

We counted rather than guessed:

MechanismCount in src/ (tests and blog copy excluded)
Files importing next/link26
useRouter call sites2 (DashboardTabs.tsx, AuthCard.tsx)
redirect() from next/navigation12 call sites across 6 files
NextResponse.redirect()1 (src/app/api/download/route.ts)
permanentRedirect()0
window.location1, a read-only ?buy= lookup in BuyButton.tsx

The ratio is the lesson. Files that render a link outnumber files that navigate programmatically 13 to 1, because most navigation is a person clicking. Programmatic navigation is rare, and each use has a reason.

// src/components/organisms/Header.tsx (excerpt)
<Link aria-label="asoc logo" href="/">

Nothing clever. In production, <Link> prefetches the target route when it enters the viewport, so the click is usually a cache hit. Next.js prefetch covers when that helps and when it floods the network. If you catch yourself writing onClick={() => router.push("/pricing")} on a button, replace it with a link. You gain middle-click, "open in new tab", prefetching and a crawlable URL.

router.push(): only after something finished

There are exactly two useRouter call sites here. The first sends a user on after a sign-in or sign-up succeeds:

// src/components/molecules/AuthCard.tsx
const router = useRouter();

useEffect(() => {
  if (state?.ok && redirectTo) {
    router.push(redirectTo);
  }
}, [state, redirectTo, router]);

The Server Action returns { ok: true }, and the effect navigates. A link cannot do this, because there is nothing to click: the destination is only valid after the action succeeds.

The second is router.replace for the dashboard's tabs:

// src/components/organisms/DashboardTabs.tsx
const select = (id: DashboardTab) => {
  setActiveTab(id);
  router.replace(`/dashboard?tab=${id}`, { scroll: false });
};

replace rather than push so ten tab clicks do not cost ten Back presses, and scroll: false so the page does not jump. The trade-off between the two methods, and the double-push bug, is in router.push with query params.

redirect(): when the request should not render

The dashboard layout gates every route beneath it:

// src/app/dashboard/layout.tsx
const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
if (!data?.claims) {
  redirect("/login?next=/dashboard");
}

An unauthenticated visitor gets a 307 before any dashboard HTML exists. That is the point of doing it on the server: with router.push in a client effect, the page would render, flash, and then leave. See Next.js auth middleware for the proxy-versus-layout split.

redirect() works by throwing. Code after it does not run, and it must not be wrapped in a try/catch that swallows the throw. That is why src/app/auth/callback/route.ts calls it as the last statement of each branch.

One measured oddity. /dashboard/settings used to be a page; it moved into a tab, so the old URL is now a page containing only redirect("/dashboard?tab=settings"). In the build's route table it still prints ƒ (dynamic), and .next/server/app/ holds no settings.html for it, where a static route such as /forgot-password has one. A redirect-only page is not prerendered into a file, which is the right result for a URL that should answer with a redirect. If you need a moved URL to keep SEO value, use redirects() in next.config.ts with permanent: true instead, so the 308 never reaches a render.

redirect() inside a Server Action

The fourth place redirect() appears is a Server Action, and it is the one that replaces most router.push habits. resendVerificationEmail in src/lib/actions/account.ts is bound straight to a <form action> in a Server Component, with no client wrapper:

export async function resendVerificationEmail(): Promise<void> {
  // ...resend the confirmation email...
  if (error) {
    redirect("/dashboard?verification=error");
  }
  redirect("/dashboard?verification=sent");
}

It never returns state. It redirects back with a status flag, and the dashboard reads ?verification= and renders a banner. The result is a flow with zero client JavaScript: submit, server work, redirect, render. Reach for useRouter only when the action returns data the client must act on, which is exactly what AuthCard does with state.ok.

The same file shows the ordering rule for deleteAccount: sign the user out and clear the cookie first, then redirect("/"). Because redirect() throws, anything you write after it is dead code.

The security edge: never navigate to a raw next parameter

Both login and signup read a next query parameter so a visitor lands back where they were. Passing that string to router.push or redirect unchecked is an open redirect. This is the guard:

// src/lib/validation.ts
const SAFE_NEXT_RE = /^\/(?![/\\])/;

export function safeNext(next: string | null | undefined): string {
  return next && SAFE_NEXT_RE.test(next) ? next : "/dashboard";
}

The regex rejects //evil.com, and also /\evil.com, because the URL parser treats a leading backslash as a slash, so /\evil.com navigates off-site. The test file src/lib/__tests__/validation.test.ts pins both cases, plus the URL-decoded form. Any navigation target that originated in a query string should pass through a function like this first.

Troubleshooting

SymptomCauseFix
useRouter throws "NextRouter was not mounted"Imported from next/router (Pages Router)Import from next/navigation
"You're importing a component that needs useRouter"Used in a Server ComponentAdd "use client", or use <Link> / redirect() instead
redirect() does nothing inside tryThe throw was caughtCall it outside the try, or rethrow
Link works but crawlers miss itIt is a button with router.pushRender a <Link>
Back button feels trappedpush used for filter or tab stateUse router.replace
Login lands on an external siteUnvalidated next paramValidate with an allowlist like safeNext
Scroll jumps after a tab changeDefault scroll-to-top on navigationPass { scroll: false }

Frequently asked questions

What is the difference between <Link> and router.push? <Link> is declarative and renders a real anchor, so it is prefetched, crawlable and works with middle-click. router.push is imperative, client-only and invisible in the HTML. Use it only when navigation follows something that finished.

How do I navigate from a Server Component? You do not navigate from it, you redirect. Call redirect(path) from next/navigation and the server answers with a 307 instead of rendering. To let the user choose, render a <Link>.

Should I use window.location.href in Next.js? Rarely. It forces a full page load, discarding client state and the router cache. This repo reads window.location.search once, for a ?buy= parameter, and never assigns to it.

Does navigating with router.push hurt SEO? Navigation itself does not, but a page reachable only through router.push has no crawlable link to it. Give every indexable page at least one <Link> or plain anchor pointing at it.

Templates that already wire navigation correctly

If you want these patterns, header, mobile menu, Link-based nav and a working sign-in redirect, in place before you write a line, start from one of the sets: ASoc Reach, ASoc Realm and ASoc Relay are Next.js landing templates built on the same conventions.

Browse the sets: Next.js landing page templates and Tailwind landing page templates.

Keep reading

Tutorial11 min read

Dynamic Open Graph Images in Next.js with ImageResponse

A metadata route under a dynamic segment needs its own generateStaticParams, or every card renders per request. That trap, the Satori CSS subset, fonts, and how to verify a crawler sees it.

Read more