In traditional Multi-Page Applications (MPAs), clicking a standard <a href="..."> link triggers a full network cycle: the browser fetches an entirely new HTML document, destroys the active DOM, and recompiles all scripts and stylesheets. This tears down user context (ongoing audio playback, unsaved form inputs, open modal overlays) and degrades Core Web Vitals—especially Interaction to Next Paint (INP).
Next.js App Router implements a hybrid navigation architecture. Page transitions are executed as Soft Navigation: the browser avoids reloading the document, while the client router requests only the updated React Server Components tree (RSC Flight Stream), preserving parent layouts and interactive state.
Combined with intelligent Prefetching and an optimized in-memory cache, this enables near-instant (0ms perceived latency) screen transitions.
Navigation architecture in Next.js 15+ and React 19 is built on four core primitives:
/dashboard/analytics and /dashboard/settings, the root layout and dashboard shell do not re-render. Only the leaf children slot updates.The <Link> component scrolls into view. Intersection Observer schedules a background prefetch request.
Browser dispatches HTTP GET with header Next-Router-Prefetch: 1. Server returns static shell or skeleton.
RSC Flight Payload is stored in client memory adhering to staleTimes (0s for dynamic routes, 300s for static).
User clicks the link. Router intercepts click (preventDefault) and renders cached UI immediately without network delay.
React mounts the updated children slot, repositions accessible focus to the segment root, and manages scroll position.
How does prefetch={false} behave in App Router compared to the legacy Pages Router?
<Link>#The <Link> component from next/link enhances the native HTML <a> tag with automatic prefetching and smooth client-side routing:
import Link from 'next/link';
export function NavBar() {
return (
<nav className="flex gap-4 p-4 border-b border-border text-sm">
{/* prefetch={null} (default): adaptive prefetch for static pages and loading skeletons */}
<Link href="/dashboard" className="hover:text-accent-violet transition-colors">
Dashboard
</Link>
{/* prefetch={true}: forces prefetching of the entire component tree */}
<Link href="/analytics" prefetch={true} className="hover:text-accent-violet transition-colors">
Analytics
</Link>
{/* scroll={false}: navigates without jumping scroll to top (ideal for tabs & pagination) */}
<Link href="/settings?tab=security" scroll={false} className="hover:text-accent-violet transition-colors">
Security Settings
</Link>
</nav>
);
}In Client Components, programmatic navigation is handled via useRouter(). In React 19, pair navigation with useTransition to track non-blocking loading state (isPending):
'use client';
import { useRouter } from 'next/navigation';
import { useTransition } from 'react';
export function CategoryFilter() {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const handleSelect = (category: string) => {
// Non-blocking transition: prevents UI stutter while fetching RSC payload
startTransition(() => {
router.push(`/catalog?category=${category}`, { scroll: false });
});
};
return (
<div className="flex items-center gap-2">
<button
onClick={() => handleSelect('laptops')}
disabled={isPending}
className="px-3 py-1.5 rounded bg-bg-elevated border border-border text-xs disabled:opacity-50"
>
{isPending ? 'Loading...' : 'Laptops'}
</button>
</div>
);
}In Server Components, Route Handlers, and Server Actions, redirections are performed via dedicated server functions that throw managed exceptions:
import { redirect } from 'next/navigation';
async function getSession() {
return null; // User is not authenticated
}
export default async function DashboardPage() {
const session = await getSession();
if (!session) {
// Throws NEXT_REDIRECT and returns HTTP 307
redirect('/login?from=/dashboard');
}
return <div>Secure Dashboard Panel</div>;
}How does router.refresh() differ from window.location.reload()?
When toggling tabbed views or pagination params, viewport jumping to (0, 0) disrupts user experience. The scroll={false} option resolves this issue:
'use client';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
export function DashboardTabs() {
const searchParams = useSearchParams();
const currentTab = searchParams.get('tab') ?? 'overview';
return (
<div className="flex border-b border-border space-x-4">
{['overview', 'logs', 'metrics'].map((tab) => (
<Link
key={tab}
href={`/dashboard?tab=${tab}`}
scroll={false} // Prevents unwanted viewport scroll to top
className={`pb-2 text-sm capitalize transition-colors ${
currentTab === tab
? 'border-b-2 border-accent-violet font-semibold text-text-primary'
: 'text-text-muted hover:text-text-primary'
}`}
>
{tab}
</Link>
))}
</div>
);
}Invoking window.location.href = '/dashboard' breaks the Next.js SPA model, forcing a full page reload, flushing the Client Router Cache, and re-downloading JavaScript bundles.
Mitigation: Always use <Link> for declarative markup or router.push() from useRouter() for programmatic transitions.
Following a data mutation on the server, the client router might continue rendering stale cached RSC data from browser memory.
Mitigation: Inside the Server Action, call revalidatePath('/dashboard') or revalidateTag('tag'), or trigger router.refresh() on the client.