Navigation
Navigate between routes using the <Link> component, programmatic useRouter, URL-reading hooks, and server-side redirect.
Recipe
Quick-reference recipe card — copy-paste ready.
// Declarative navigation
import Link from "next/link";
<Link href="/dashboard">Dashboard</Link>
// Programmatic navigation (Client Component)
"use client";
import { useRouter } from "next/navigation";
const router = useRouter();
router.push("/dashboard");
// Read current URL (Client Component)
import { usePathname, useSearchParams } from "next/navigation";
const pathname = usePathname(); // "/dashboard"
const searchParams = useSearchParams(); // URLSearchParams instance
// Server-side redirect (Server Component or Server Action)
import { redirect } from "next/navigation";
redirect("/login");When to reach for this: Any time you need to move between pages, read the current URL, or redirect users based on conditions.
Working Example
// components/nav-bar.tsx — Navigation with active link highlighting
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const links = [
{ href: "/", label: "Home" },
{ href: "/dashboard", label: "Dashboard" },
{ href: "/settings", label: "Settings" },
];
export function NavBar() {
const pathname = usePathname();
return (
<nav className="flex gap-4 border-b px-6 py-3">
{links.map((link) => (
<Link
key={link.href}
href={link.href}
className={
pathname === link.href
? "font-bold text-blue-600"
: "text-gray-600 hover:text-gray-900"
}
>
{link.label}
</Link>
))}
</nav>
);
}// components/search-filter.tsx — Search params for filtering
"use client";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { useCallback } from "react";
export function SearchFilter() {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const currentQuery = searchParams.get("q") ?? "";
const currentSort = searchParams.get("sort") ?? "newest";
const updateParams = useCallback(
(key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
router.push(`${pathname}?${params.toString()}`);
},
[searchParams, pathname, router]
);
return (
<div className="flex gap-4">
<input
type="text"
placeholder="Search..."
defaultValue={currentQuery}
onChange={(e) => updateParams("q", e.target.value)}
className="rounded border px-3 py-2"
/>
<select
value={currentSort}
onChange={(e) => updateParams("sort", e.target.value)}
className="rounded border px-3 py-2"
>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="popular">Popular</option>
</select>
</div>
);
}// app/login/page.tsx — Server-side redirect after auth check
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
export default async function LoginPage() {
const session = await auth();
if (session) redirect("/dashboard");
return (
<form action="/api/auth/login" method="POST">
<input name="email" type="email" placeholder="Email" />
<input name="password" type="password" placeholder="Password" />
<button type="submit">Log In</button>
</form>
);
}// Programmatic navigation after form submission
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
export function CreatePostForm() {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [title, setTitle] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const res = await fetch("/api/posts", {
method: "POST",
body: JSON.stringify({ title }),
headers: { "Content-Type": "application/json" },
});
const post = await res.json();
startTransition(() => {
router.push(`/blog/${post.slug}`);
router.refresh(); // Revalidate server data
});
}
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Post title"
className="rounded border px-3 py-2"
/>
<button
type="submit"
disabled={isPending}
className="ml-2 rounded bg-blue-600 px-4 py-2 text-white"
>
{isPending ? "Creating..." : "Create"}
</button>
</form>
);
}Deep Dive
How It Works
<Link>prefetches by default. When a<Link>enters the viewport, Next.js prefetches the route in the background. Static routes are fully prefetched; dynamic routes prefetch up to the nearestloading.tsxboundary.<Link>performs client-side navigation. It intercepts the click, updates the URL, and renders the new route without a full page reload. Only the changed segments re-render.useRouterprovides imperative navigation.push(),replace(),back(),forward(), andrefresh()let you navigate programmatically.router.refresh()re-fetches server data. It revalidates the current route's Server Components without losing client-side state (input values, scroll position).usePathname()returns the current path. It is reactive — the component re-renders when the URL changes. It does not include search params or hash.useSearchParams()returns a read-onlyURLSearchParams. To update search params, construct a new URL string and userouter.push().redirect()throws internally. It must be called outsidetry/catchblocks (or useunstable_rethrowin the catch). Code afterredirect()never runs.redirect()defaults to 307 (temporary). Useredirect(url, RedirectType.replace)for 308 (permanent) or usepermanentRedirect().- Navigation hooks require Client Components.
useRouter,usePathname, anduseSearchParamsall require the"use client"directive.
Variations
// Link with dynamic href and prefetch control
import Link from "next/link";
// Disable prefetch for rarely visited links
<Link href="/terms" prefetch={false}>Terms</Link>
// Dynamic href with template literal
<Link href={`/blog/${slug}`}>Read More</Link>
// Link with replace (no new history entry)
<Link href="/dashboard" replace>Dashboard</Link>
// Link with scroll={false} to preserve scroll position
<Link href="/dashboard?tab=settings" scroll={false}>Settings Tab</Link>// permanentRedirect — 308 redirect
import { permanentRedirect } from "next/navigation";
export default async function OldPage() {
permanentRedirect("/new-page");
}// redirect in Server Actions
"use server";
import { redirect } from "next/navigation";
export async function createPost(formData: FormData) {
const post = await db.post.create({
data: { title: formData.get("title") as string },
});
redirect(`/blog/${post.slug}`);
}// useSelectedLayoutSegment — read active child segment
"use client";
import { useSelectedLayoutSegment } from "next/navigation";
export function TabNav() {
const segment = useSelectedLayoutSegment();
// On /dashboard/analytics → segment = "analytics"
// On /dashboard → segment = null
return (
<nav>
<Link
href="/dashboard"
className={segment === null ? "font-bold" : ""}
>
Overview
</Link>
<Link
href="/dashboard/analytics"
className={segment === "analytics" ? "font-bold" : ""}
>
Analytics
</Link>
</nav>
);
}TypeScript Notes
// Link component props
import type { LinkProps } from "next/link";
// Key props: href (string or UrlObject), replace, scroll, prefetch
// useRouter return type
import { useRouter } from "next/navigation";
// router.push(href: string, options?: { scroll?: boolean }): void
// router.replace(href: string, options?: { scroll?: boolean }): void
// router.refresh(): void
// router.back(): void
// router.forward(): void
// router.prefetch(href: string): void
// redirect function signature
import { redirect, permanentRedirect, RedirectType } from "next/navigation";
// redirect(url: string, type?: RedirectType): never
// permanentRedirect(url: string, type?: RedirectType): never
// URL object for complex hrefs
<Link
href={{
pathname: "/blog/[slug]",
query: { slug: "hello-world" },
}}
>
Read Post
</Link>Gotchas
useRouteris fromnext/navigation, notnext/router. Thenext/routerversion is for the Pages Router and will not work in App Router.redirect()throws an error internally. If called inside atry/catch, it will be caught. Useunstable_rethrow(error)in catch blocks that might intercept it.useSearchParams()must be wrapped in<Suspense>. In Next.js 15+, usinguseSearchParams()without a Suspense boundary causes the entire route to opt into client-side rendering. Wrap the component using it in<Suspense>.router.push()does not await. Navigation is asynchronous but the method returnsvoid. UseuseTransitionto track pending state.<Link>prefetches on viewport entry. This can cause unexpected network requests. Useprefetch={false}for links users rarely click.router.refresh()does not clear the router cache. It only re-fetches the current page's server data. Other cached routes remain stale.searchParamschanges do not triggerloading.tsx. Only segment changes (different path) trigger the loading boundary. Search param changes re-render without loading UI.redirect()in a layout applies to all child pages. Be careful placing conditional redirects in layouts — they run for every child route.- The
scrollprop on<Link>defaults totrue. Navigation scrolls to top by default. Setscroll={false}for tab-like navigation or filtered lists.
Alternatives
| Approach | When to Use |
|---|---|
<a> tag | External links or when you need a full page reload |
window.location | Escaping the SPA — full reload to a different URL |
| Middleware redirect | Redirecting before any rendering occurs |
next.config.js redirects | Static redirect rules without runtime logic |
Server Action with redirect() | Redirecting after a mutation |
revalidatePath or revalidateTag | Refreshing data without navigation |
Real-World Example
From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).
// Production example: Centralized URL builder
// File: src/lib/url-builder.ts
import { generateSlug } from './slug-utils';
export function buildServiceUrlFromSlug(slug: string): string {
return `/services/${slug}`;
}
export function buildSectionUrl(serviceSlug: string, sectionId: string): string {
const cleanSectionId = stripServicePrefix(sectionId, serviceSlug);
return `/services/${serviceSlug}/${cleanSectionId}`;
}
export function buildPointUrl(
serviceSlug: string,
sectionId: string,
pointSlug: string
): string {
const cleanSectionId = stripServicePrefix(sectionId, serviceSlug);
const cleanPointSlug = stripSectionPrefix(pointSlug, sectionId);
return `/services/${serviceSlug}/${cleanSectionId}/pt/${cleanPointSlug}`;
}
export function buildPointTabUrl(
serviceSlug: string,
sectionId: string,
pointSlug: string,
tabSlug: string | null
): string {
const baseUrl = buildPointUrl(serviceSlug, sectionId, pointSlug);
if (tabSlug === null) return baseUrl;
return `${baseUrl}/${tabSlug}`;
}
export function parseServiceUrl(url: string): string | null {
const match = url.match(/^\/services\/([^\/]+)\/?$/);
return match ? match[1] : null;
}What this demonstrates in production:
- Each function builds one level of the URL hierarchy: service, section, point, tab
- This file is the ONLY place where URL patterns like
/services/...are defined. Changing the pattern here updates the entire app stripServicePrefixandstripSectionPrefixclean up database IDs that may contain redundant prefixes (legacy data normalization)parseServiceUrlis the inverse, extracting a slug from a URL using regex- The
pt/segment in point URLs distinguishes points from sections in the routing hierarchy - Never use template strings for URLs elsewhere. Centralized builders prevent broken links when URL patterns change
FAQs
What is the difference between Link and useRouter for navigation?
<Link>is declarative, prefetches by default, and is the standard approach for most navigationuseRouteris imperative, used for programmatic navigation after events like form submissions- Both perform client-side navigation without full page reloads
Gotcha: Which useRouter import should you use in the App Router?
Import from next/navigation, not next/router. The next/router version is for the Pages Router and will not work in App Router components.
How does Link prefetching work?
When a <Link> enters the viewport, Next.js prefetches the route in the background. Static routes are fully prefetched. Dynamic routes prefetch up to the nearest loading.tsx boundary. Use prefetch={false} for rarely visited links.
What does router.refresh() do?
It re-fetches the current route's Server Components without losing client-side state (input values, scroll position). It does not clear the router cache for other routes.
Gotcha: What happens if redirect() is called inside a try/catch?
redirect() throws an error internally, so the catch block intercepts it. Use unstable_rethrow(error) in catch blocks that might accidentally catch redirect() or notFound().
Why must useSearchParams be wrapped in Suspense?
In Next.js 15+, using useSearchParams() without a <Suspense> boundary causes the entire route to opt into client-side rendering. Wrap the component using it in <Suspense>.
How do you update search params without a full navigation?
"use client";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const params = new URLSearchParams(searchParams.toString());
params.set("sort", "newest");
router.push(`${pathname}?${params.toString()}`);Do search param changes trigger loading.tsx?
No. Only segment changes (different path) trigger the loading boundary. Search param changes re-render the page without showing the loading UI.
What is the difference between redirect() and permanentRedirect()?
redirect()sends a 307 (temporary) redirect by defaultpermanentRedirect()sends a 308 (permanent) redirect- Both throw internally and code after them never runs
What are the TypeScript types for useRouter methods?
import { useRouter } from "next/navigation";
const router = useRouter();
// router.push(href: string, options?: { scroll?: boolean }): void
// router.replace(href: string, options?: { scroll?: boolean }): void
// router.refresh(): void
// router.back(): void
// router.forward(): void
// router.prefetch(href: string): voidHow do you track pending state during programmatic navigation?
Use useTransition since router.push() returns void and does not await.
const [isPending, startTransition] = useTransition();
startTransition(() => {
router.push("/dashboard");
});
// isPending is true while navigatingWhat does the scroll prop on Link do?
It defaults to true, scrolling to the top of the page on navigation. Set scroll={false} for tab-like navigation or filtered lists where you want to preserve scroll position.
How does useSelectedLayoutSegment help with active link highlighting?
"use client";
import { useSelectedLayoutSegment } from "next/navigation";
export function TabNav() {
const segment = useSelectedLayoutSegment();
// /dashboard/analytics -> "analytics"
// /dashboard -> null
return (
<Link
href="/dashboard/analytics"
className={segment === "analytics" ? "font-bold" : ""}
>
Analytics
</Link>
);
}Related
- App Router Basics — file conventions overview
- Dynamic Routes — linking to parameterized routes
- Middleware — request-level redirects and rewrites
- Loading & Error — loading states during navigation
- Intercepting Routes — modal navigation patterns