Navigation
Navigate between routes using the <Link> component, programmatic useRouter, URL-reading hooks, and server-side redirect.
Search across all documentation pages
Navigate between routes using the <Link> component, programmatic useRouter, URL-reading hooks, and server-side redirect.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
// 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>
);
}<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 nearest loading.tsx boundary.<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.useRouter provides imperative navigation. push(), replace(), back(), forward(), and refresh() 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-only URLSearchParams. To update search params, construct a new URL string and use router.push().redirect() throws internally. It must be called outside try/catch blocks (or use unstable_rethrow in the catch). Code after redirect() never runs.redirect() defaults to 307 (temporary). Use redirect(url, RedirectType.replace) for 308 (permanent) or use permanentRedirect().useRouter, usePathname, and useSearchParams all require the "use client" directive.// 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>
);
}// 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>useRouter is from next/navigation, not next/router. The next/router version is for the Pages Router and will not work in App Router.redirect() throws an error internally. If called inside a try/catch, it will be caught. Use unstable_rethrow(error) in catch blocks that might intercept it.useSearchParams() must 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>.router.push() does not await. Navigation is asynchronous but the method returns void. Use useTransition to track pending state.<Link> prefetches on viewport entry. This can cause unexpected network requests. Use prefetch={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.searchParams changes do not trigger loading.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.scroll prop on <Link> defaults to true. Navigation scrolls to top by default. Set scroll={false} for tab-like navigation or filtered lists.| 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 |
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:
/services/... are defined. Changing the pattern here updates the entire appstripServicePrefix and stripSectionPrefix clean up database IDs that may contain redundant prefixes (legacy data normalization)parseServiceUrl is the inverse, extracting a slug from a URL using regexpt/ segment in point URLs distinguishes points from sections in the routing hierarchy<Link> is declarative, prefetches by default, and is the standard approach for most navigationuseRouter is imperative, used for programmatic navigation after events like form submissionsImport from next/navigation, not next/router. The next/router version is for the Pages Router and will not work in App Router components.
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.
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.
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().
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>.
"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()}`);No. Only segment changes (different path) trigger the loading boundary. Search param changes re-render the page without showing the loading UI.
redirect() sends a 307 (temporary) redirect by defaultpermanentRedirect() sends a 308 (permanent) redirectimport { 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): voidUse useTransition since router.push() returns void and does not await.
const [isPending, startTransition] = useTransition();
startTransition(() => {
router.push("/dashboard");
});
// isPending is true while navigatingIt 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.
"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>
);
}Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥