NextJS DOC
Part 3 of 15Next.js Linking and Navigation: Complete Architecture Guide
Next.js Linking and Navigation: Complete Architecture Guide
Introduction: Server-First Navigation Done Right
Next.js fundamentally reimagines navigation for server-rendered applications. The challenge: server rendering means waiting for responses, which traditionally makes navigation feel sluggish. Next.js solves this through a sophisticated combination of prefetching, streaming, and client-side transitions that make server-rendered apps feel as responsive as SPAs.
This guide dissects how navigation actually works under the hood, the optimization strategies Next.js employs, and how to leverage these mechanics for optimal user experience.
Navigation Architecture Overview
┌─────────────────────────────────────────────────────────────────────┐
│ NEXT.JS NAVIGATION FLOW │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ User clicks <Link href="/dashboard"> │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 1. CHECK PREFETCH CACHE │ │
│ │ Is the route already prefetched? │ │
│ │ ├── YES (static) → Full RSC payload available │ │
│ │ ├── YES (dynamic) → loading.tsx shell available │ │
│ │ └── NO → Fetch begins now │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 2. CLIENT-SIDE TRANSITION │ │
│ │ • URL updates immediately (history.pushState) │ │
│ │ • Layouts preserved (no re-render) │ │
│ │ • Loading UI shown (if streaming) │ │
│ │ • Scroll position handled │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 3. SERVER COMPONENT PAYLOAD │ │
│ │ • RSC payload streamed from server │ │
│ │ • React reconciles new content │ │
│ │ • Loading UI replaced with actual content │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Navigation Complete - No full page reload │
│ │
└─────────────────────────────────────────────────────────────────────┘
How Navigation Works: The Four Pillars
1. Server Rendering
Next.js renders routes on the server by default. Understanding the two rendering modes is crucial for navigation optimization:
┌─────────────────────────────────────────────────────────────────────┐
│ SERVER RENDERING MODES │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ PRERENDERING (Static) │
│ ───────────────────── │
│ When: Build time or revalidation │
│ Result: Cached HTML + RSC payload │
│ Prefetch: Full route prefetched │
│ Navigation: Instant (from cache) │
│ │
│ Triggers: │
│ • No dynamic functions (cookies, headers, searchParams) │
│ • generateStaticParams provided for dynamic segments │
│ • force-static directive │
│ │
│ ═══════════════════════════════════════════════════════════════ │
│ │
│ DYNAMIC RENDERING │
│ ───────────────── │
│ When: Request time │
│ Result: Fresh HTML + RSC payload per request │
│ Prefetch: Skipped OR partial (loading.tsx only) │
│ Navigation: Requires server round-trip │
│ │
│ Triggers: │
│ • Using cookies(), headers() │
│ • Using searchParams prop │
│ • Uncached data fetches │
│ • force-dynamic directive │
│ │
└─────────────────────────────────────────────────────────────────────┘
2. Prefetching
Prefetching loads routes in the background before user navigation. This is the key to instant-feeling navigation.
// Automatic prefetching with <Link>
import Link from 'next/link';
export function Navigation() {
return (
<nav>
{/* Prefetched when visible in viewport OR on hover */}
<Link href="/about">About</Link>
{/* Prefetched with default behavior */}
<Link href="/blog">Blog</Link>
{/* Explicitly enable full prefetch (default for static) */}
<Link href="/pricing" prefetch={true}>
Pricing
</Link>
{/* Disable prefetching entirely */}
<Link href="/large-page" prefetch={false}>
Large Page
</Link>
{/* Native anchor - NO prefetching */}
<a href="/contact">Contact</a>
</nav>
);
}
Prefetch Behavior by Route Type:
| Route Type | Prefetch Behavior | What's Cached |
|---|---|---|
| Static | Full prefetch | Complete RSC payload |
| Dynamic (no loading.tsx) | Skipped | Nothing |
| Dynamic (with loading.tsx) | Partial prefetch | Layouts + loading.tsx shell |
3. Streaming
Streaming enables partial page delivery, showing UI progressively as it becomes ready:
┌─────────────────────────────────────────────────────────────────────┐
│ STREAMING TIMELINE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ WITHOUT STREAMING (Traditional) │
│ ──────────────────────────────── │
│ │
│ Request ──────────────────────────────────────────────► Response │
│ │◄─────── Server renders entire page ───────►│ │
│ │ │ │
│ User │ [Blank screen / Loading...] │ Content │
│ clicks │ │ appears │
│ │◄──────────── ~2000ms ─────────────────────►│ │
│ │
│ ═══════════════════════════════════════════════════════════════ │
│ │
│ WITH STREAMING (loading.tsx) │
│ ──────────────────────────── │
│ │
│ Request ──┬───────────────────────────────────────────► Response │
│ │ │
│ ├─► Shell + loading.tsx (50ms) │
│ │ [Skeleton UI appears immediately] │
│ │ │
│ ├─► Streamed chunk 1 (200ms) │
│ │ [Header content replaces skeleton] │
│ │ │
│ ├─► Streamed chunk 2 (500ms) │
│ │ [Main content replaces skeleton] │
│ │ │
│ └─► Final chunk (800ms) │
│ [Remaining content, all skeletons replaced] │
│ │
└─────────────────────────────────────────────────────────────────────┘
Implementation:
// app/dashboard/loading.tsx
// Automatically wraps page.tsx in Suspense
export default function DashboardLoading() {
return (
<div className="dashboard-skeleton">
<div className="skeleton-header animate-pulse" />
<div className="skeleton-grid">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="skeleton-card animate-pulse" />
))}
</div>
</div>
);
}
// What Next.js generates internally:
// <Layout>
// <Suspense fallback={<DashboardLoading />}>
// <DashboardPage />
// </Suspense>
// </Layout>
4. Client-Side Transitions
Unlike traditional server-rendered navigation (full page reload), Next.js performs client-side transitions:
┌─────────────────────────────────────────────────────────────────────┐
│ TRADITIONAL VS NEXT.JS NAVIGATION │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ TRADITIONAL (Full Page Load) │
│ ───────────────────────────── │
│ 1. Browser requests new page │
│ 2. Server returns full HTML │
│ 3. Browser parses HTML, loads CSS/JS │
│ 4. JavaScript re-initializes │
│ 5. React re-hydrates entire tree │
│ │
│ Problems: │
│ • White flash between pages │
│ • All state lost │
│ • Scroll position reset │
│ • Layouts re-rendered unnecessarily │
│ │
│ ═══════════════════════════════════════════════════════════════ │
│ │
│ NEXT.JS (Client-Side Transition) │
│ ──────────────────────────────── │
│ 1. URL updates via history.pushState │
│ 2. Prefetched payload used (or fetched) │
│ 3. React reconciles only changed segments │
│ 4. Layouts preserved, only page swapped │
│ 5. Scroll handled intelligently │
│ │
│ Benefits: │
│ • No white flash │
│ • Layout state preserved │
│ • Smooth scroll behavior │
│ • Minimal re-rendering │
│ │
└─────────────────────────────────────────────────────────────────────┘
The <Link> Component: Complete API
Basic Usage
import Link from 'next/link';
export function Examples() {
return (
<div>
{/* Basic link */}
<Link href="/about">About Us</Link>
{/* Dynamic route */}
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
{/* With query parameters */}
<Link
href={{
pathname: '/search',
query: { q: 'next.js', page: '1' },
}}
>
Search Results
</Link>
{/* With hash */}
<Link href="/docs#installation">Installation Guide</Link>
</div>
);
}
All Props Explained
import Link from 'next/link';
export function LinkPropsDemo() {
return (
<>
{/*
href (required)
The path or URL to navigate to
*/}
<Link href="/dashboard">Dashboard</Link>
<Link href={{ pathname: '/blog/[slug]', query: { slug: 'hello' } }}>
Blog Post
</Link>
{/*
prefetch
- null (default): Static routes fully prefetched, dynamic partially
- true: Always prefetch fully
- false: Never prefetch
*/}
<Link href="/heavy-page" prefetch={false}>
Heavy Page
</Link>
{/*
replace
Replace current history entry instead of pushing
User cannot go "back" to current page
*/}
<Link href="/login" replace>
Login
</Link>
{/*
scroll
- true (default): Scroll to top after navigation
- false: Maintain scroll position
*/}
<Link href="/same-page-section" scroll={false}>
Section Link
</Link>
{/*
passHref
Forces Link to pass href to child component
Required when child is a custom component wrapping <a>
*/}
<Link href="/styled" passHref legacyBehavior>
<StyledAnchor>Styled Link</StyledAnchor>
</Link>
{/*
shallow (Pages Router only, not App Router)
Update URL without re-running data fetching methods
*/}
</>
);
}
Styling Active Links
// app/_components/NavLink.tsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
interface NavLinkProps {
href: string;
children: React.ReactNode;
exact?: boolean;
className?: string;
activeClassName?: string;
}
export function NavLink({
href,
children,
exact = false,
className = '',
activeClassName = 'active',
}: NavLinkProps) {
const pathname = usePathname();
// Determine if link is active
const isActive = exact
? pathname === href
: pathname.startsWith(href) && (href !== '/' || pathname === '/');
return (
<Link
href={href}
className={`${className} ${isActive ? activeClassName : ''}`}
aria-current={isActive ? 'page' : undefined}
>
{children}
</Link>
);
}
// Usage
export function Navigation() {
return (
<nav className="nav">
<NavLink href="/" exact className="nav-link" activeClassName="nav-link-active">
Home
</NavLink>
<NavLink href="/dashboard" className="nav-link" activeClassName="nav-link-active">
Dashboard
</NavLink>
<NavLink href="/settings" className="nav-link" activeClassName="nav-link-active">
Settings
</NavLink>
</nav>
);
}
Link with Loading Indicator (useLinkStatus)
For slow networks where prefetch may not complete before click:
// app/_components/LinkWithIndicator.tsx
'use client';
import Link, { useLinkStatus } from 'next/link';
import { ReactNode } from 'react';
interface LinkWithIndicatorProps {
href: string;
children: ReactNode;
}
export function LinkWithIndicator({ href, children }: LinkWithIndicatorProps) {
return (
<Link href={href} className="link-with-indicator">
{children}
<LoadingIndicator />
</Link>
);
}
function LoadingIndicator() {
const { pending } = useLinkStatus();
return (
<span
className={`loading-dot ${pending ? 'loading-dot-active' : ''}`}
aria-hidden="true"
/>
);
}
/* Debounced loading indicator - only shows if navigation takes > 100ms */
.loading-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
opacity: 0;
transition: opacity 0.2s;
}
.loading-dot-active {
/* Delay showing indicator to avoid flash on fast navigations */
animation: fade-in 0.2s ease-in-out 0.1s forwards;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
Prefetch on Hover Only
For large lists where prefetching everything is wasteful:
// app/_components/HoverPrefetchLink.tsx
'use client';
import Link from 'next/link';
import { useState, useCallback } from 'react';
interface HoverPrefetchLinkProps {
href: string;
children: React.ReactNode;
className?: string;
}
export function HoverPrefetchLink({
href,
children,
className,
}: HoverPrefetchLinkProps) {
const [shouldPrefetch, setShouldPrefetch] = useState(false);
const handleMouseEnter = useCallback(() => {
setShouldPrefetch(true);
}, []);
return (
<Link
href={href}
// prefetch={null} = default behavior
// prefetch={false} = disabled
prefetch={shouldPrefetch ? null : false}
onMouseEnter={handleMouseEnter}
onFocus={handleMouseEnter} // Keyboard accessibility
className={className}
>
{children}
</Link>
);
}
// Usage in a large list
export function ProductList({ products }: { products: Product[] }) {
return (
<ul>
{products.map((product) => (
<li key={product.id}>
<HoverPrefetchLink href={`/products/${product.id}`}>
{product.name}
</HoverPrefetchLink>
</li>
))}
</ul>
);
}
Programmatic Navigation: useRouter
Complete API
'use client';
import { useRouter } from 'next/navigation';
import { useTransition } from 'react';
export function NavigationDemo() {
const router = useRouter();
const [isPending, startTransition] = useTransition();
return (
<div>
{/*
push(href, options?)
Navigate to a new URL, adds entry to history stack
*/}
<button onClick={() => router.push('/dashboard')}>
Go to Dashboard
</button>
{/* With scroll disabled */}
<button onClick={() => router.push('/long-page', { scroll: false })}>
Navigate Without Scroll
</button>
{/*
replace(href, options?)
Navigate without adding history entry
Back button won't return to current page
*/}
<button onClick={() => router.replace('/login')}>
Replace with Login
</button>
{/*
back()
Navigate to previous history entry
Equivalent to browser back button
*/}
<button onClick={() => router.back()}>
Go Back
</button>
{/*
forward()
Navigate to next history entry
Equivalent to browser forward button
*/}
<button onClick={() => router.forward()}>
Go Forward
</button>
{/*
refresh()
Refresh current route
Re-fetches Server Components, preserves client state
*/}
<button onClick={() => router.refresh()}>
Refresh Data
</button>
{/*
prefetch(href)
Programmatically prefetch a route
Useful for routes not linked via <Link>
*/}
<button
onMouseEnter={() => router.prefetch('/heavy-route')}
onClick={() => router.push('/heavy-route')}
>
Heavy Route
</button>
{/*
Using with useTransition for non-blocking navigation
Shows loading state without blocking UI
*/}
<button
disabled={isPending}
onClick={() => {
startTransition(() => {
router.push('/slow-route');
});
}}
>
{isPending ? 'Loading...' : 'Go to Slow Route'}
</button>
</div>
);
}
Common Patterns
// Pattern 1: Redirect after form submission
'use client';
import { useRouter } from 'next/navigation';
import { useTransition } from 'react';
export function CreatePostForm() {
const router = useRouter();
const [isPending, startTransition] = useTransition();
async function handleSubmit(formData: FormData) {
const result = await createPost(formData);
if (result.success) {
startTransition(() => {
// Redirect to the new post
router.push(`/posts/${result.post.id}`);
// Refresh to show updated data in lists
router.refresh();
});
}
}
return (
<form action={handleSubmit}>
{/* form fields */}
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}
// Pattern 2: Conditional navigation
export function ProtectedAction() {
const router = useRouter();
async function handleAction() {
const isAuthenticated = await checkAuth();
if (!isAuthenticated) {
// Redirect to login with return URL
router.push(`/login?returnTo=${encodeURIComponent(window.location.pathname)}`);
return;
}
// Proceed with action
await performAction();
router.refresh();
}
return <button onClick={handleAction}>Protected Action</button>;
}
// Pattern 3: Dynamic navigation based on data
export function SearchForm() {
const router = useRouter();
const [query, setQuery] = useState('');
function handleSearch(e: React.FormEvent) {
e.preventDefault();
if (query.trim()) {
router.push(`/search?q=${encodeURIComponent(query.trim())}`);
}
}
return (
<form onSubmit={handleSearch}>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<button type="submit">Search</button>
</form>
);
}
Native History API Integration
Next.js integrates with the browser's History API, allowing URL updates without triggering navigation:
window.history.pushState
Adds a new entry to history stack (user can go back):
'use client';
import { useSearchParams, usePathname } from 'next/navigation';
export function FilterPanel() {
const searchParams = useSearchParams();
const pathname = usePathname();
function updateFilter(key: string, value: string) {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
// Update URL without navigation
// User can use back button to return to previous filter state
window.history.pushState(null, '', `${pathname}?${params.toString()}`);
}
function updateMultipleFilters(filters: Record<string, string>) {
const params = new URLSearchParams(searchParams.toString());
Object.entries(filters).forEach(([key, value]) => {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
window.history.pushState(null, '', `${pathname}?${params.toString()}`);
}
return (
<div className="filter-panel">
<select onChange={(e) => updateFilter('category', e.target.value)}>
<option value="">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
<select onChange={(e) => updateFilter('sort', e.target.value)}>
<option value="">Default Sort</option>
<option value="price-asc">Price: Low to High</option>
<option value="price-desc">Price: High to Low</option>
</select>
<button
onClick={() =>
updateMultipleFilters({
category: 'electronics',
sort: 'price-asc',
inStock: 'true',
})
}
>
Apply Preset
</button>
</div>
);
}
window.history.replaceState
Replaces current history entry (user cannot go back to previous state):
'use client';
import { usePathname } from 'next/navigation';
export function LocaleSwitcher() {
const pathname = usePathname();
function switchLocale(locale: string) {
// Remove current locale prefix if exists
const pathWithoutLocale = pathname.replace(/^\/(en|fr|de)/, '');
// Build new path with selected locale
const newPath = `/${locale}${pathWithoutLocale || '/'}`;
// Replace instead of push - user shouldn't go "back" to previous locale
window.history.replaceState(null, '', newPath);
// Trigger a refresh to load new locale content
// This is needed because we're not using router.push
window.location.reload();
}
return (
<div className="locale-switcher">
<button onClick={() => switchLocale('en')}>English</button>
<button onClick={() => switchLocale('fr')}>Français</button>
<button onClick={() => switchLocale('de')}>Deutsch</button>
</div>
);
}
// Another use case: Tab state that shouldn't pollute history
export function TabPanel() {
const [activeTab, setActiveTab] = useState('overview');
function handleTabChange(tab: string) {
setActiveTab(tab);
// Update URL for shareability, but don't add history entry
// User shouldn't have to click back through every tab they visited
window.history.replaceState(null, '', `?tab=${tab}`);
}
return (
<div>
<div className="tabs">
<button onClick={() => handleTabChange('overview')}>Overview</button>
<button onClick={() => handleTabChange('analytics')}>Analytics</button>
<button onClick={() => handleTabChange('settings')}>Settings</button>
</div>
<div className="tab-content">
{activeTab === 'overview' && <Overview />}
{activeTab === 'analytics' && <Analytics />}
{activeTab === 'settings' && <Settings />}
</div>
</div>
);
}
Navigation Performance Optimization
Problem: Dynamic Routes Without loading.tsx
┌─────────────────────────────────────────────────────────────────────┐
│ DYNAMIC ROUTE NAVIGATION │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ WITHOUT loading.tsx │
│ ─────────────────── │
│ │
│ Click ────────────────────────────────────────────────► Content │
│ │ │ │
│ │ [Nothing happens - app feels frozen] │ │
│ │ │ │
│ │◄──────── User waits ~1000ms+ ───────────────►│ │
│ │
│ User perception: "Is the app broken?" │
│ │
│ ═══════════════════════════════════════════════════════════════ │
│ │
│ WITH loading.tsx │
│ ──────────────── │
│ │
│ Click ──┬─────────────────────────────────────────────► Content │
│ │ │ │
│ ├─► Skeleton (50ms) ──────────────────────────►│ │
│ │ [Immediate visual feedback] │ │
│ │
│ User perception: "App is loading, working as expected" │
│ │
└─────────────────────────────────────────────────────────────────────┘
Solution: Always add loading.tsx to dynamic routes:
// app/blog/[slug]/loading.tsx
export default function BlogPostLoading() {
return (
<article className="blog-post-skeleton">
{/* Title skeleton */}
<div className="skeleton h-10 w-3/4 mb-4" />
{/* Meta skeleton */}
<div className="flex gap-4 mb-8">
<div className="skeleton h-4 w-24" />
<div className="skeleton h-4 w-32" />
</div>
{/* Content skeleton */}
<div className="space-y-4">
<div className="skeleton h-4 w-full" />
<div className="skeleton h-4 w-full" />
<div className="skeleton h-4 w-5/6" />
<div className="skeleton h-4 w-full" />
<div className="skeleton h-4 w-4/5" />
</div>
</article>
);
}
Problem: Missing generateStaticParams
Dynamic segments that could be static but aren't pre-generated:
// app/blog/[slug]/page.tsx
// WITHOUT generateStaticParams:
// Every request triggers dynamic rendering
// No prefetching possible
// WITH generateStaticParams:
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
// Control behavior for non-generated params
export const dynamicParams = true; // Generate on-demand (default)
// export const dynamicParams = false; // Return 404
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return <article>{/* ... */}</article>;
}
Problem: Slow Networks
Prefetch may not complete before user clicks:
// Solution 1: Global loading indicator with useLinkStatus
// app/_components/GlobalLoadingIndicator.tsx
'use client';
import { useLinkStatus } from 'next/link';
export function GlobalLoadingIndicator() {
const { pending } = useLinkStatus();
if (!pending) return null;
return (
<div className="fixed top-0 left-0 right-0 z-50">
<div className="h-1 bg-blue-500 animate-progress" />
</div>
);
}
// Solution 2: NProgress-style loading bar
// app/_components/NavigationProgress.tsx
'use client';
import { useEffect, useState } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
export function NavigationProgress() {
const pathname = usePathname();
const searchParams = useSearchParams();
const [isNavigating, setIsNavigating] = useState(false);
useEffect(() => {
// Navigation complete
setIsNavigating(false);
}, [pathname, searchParams]);
// Listen for navigation start via custom event or router events
useEffect(() => {
const handleStart = () => setIsNavigating(true);
// You'd need to dispatch this from Link clicks
window.addEventListener('navigation-start', handleStart);
return () => window.removeEventListener('navigation-start', handleStart);
}, []);
if (!isNavigating) return null;
return (
<div className="fixed top-0 left-0 right-0 h-1 bg-gray-200 z-50">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{ width: '80%' }} // Animate this based on loading state
/>
</div>
);
}
Problem: Large Bundle Delaying Hydration
// Symptoms:
// - <Link> components don't prefetch immediately
// - First clicks feel slow
// Solutions:
// 1. Analyze bundle
// npm install @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// config
});
// Run: ANALYZE=true npm run build
// 2. Move heavy components to server
// Before (client bundle includes charting library)
'use client';
import { Chart } from 'heavy-chart-library';
// After (chart rendered on server, only HTML sent)
import { Chart } from 'heavy-chart-library';
// No 'use client' - this is a Server Component
// 3. Dynamic imports for heavy client components
import dynamic from 'next/dynamic';
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
loading: () => <Skeleton />,
ssr: false, // Only load on client if needed
});
Scroll Behavior
Default Behavior
Next.js scrolls to top on navigation by default. Customize this:
// Disable scroll on specific link
<Link href="/same-section" scroll={false}>
Stay Here
</Link>
// Disable scroll programmatically
router.push('/path', { scroll: false });
Scroll to Hash
// Automatic scroll to element with id="features"
<Link href="/page#features">Features</Link>
// Smooth scroll
// Add to globals.css
// html { scroll-behavior: smooth; }
Handling Sticky Headers
When content scrolls behind fixed headers:
/* Add scroll padding to account for header height */
html {
scroll-padding-top: 80px; /* Height of your fixed header */
}
/* Or use CSS variable */
:root {
--header-height: 80px;
}
html {
scroll-padding-top: var(--header-height);
}
Restore Scroll Position
For lists/feeds where users expect to return to their position:
'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
const scrollPositions = new Map<string, number>();
export function ScrollRestoration() {
const pathname = usePathname();
// Save scroll position before navigation
useEffect(() => {
const handleBeforeUnload = () => {
scrollPositions.set(pathname, window.scrollY);
};
window.addEventListener('beforeunload', handleBeforeUnload);
// Restore position
const savedPosition = scrollPositions.get(pathname);
if (savedPosition) {
window.scrollTo(0, savedPosition);
}
return () => {
// Save position when leaving
scrollPositions.set(pathname, window.scrollY);
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [pathname]);
return null;
}
Navigation Events and Intercepting
Listening to Route Changes
'use client';
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
export function RouteChangeListener() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
// This runs on every route change
const url = `${pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
// Analytics
trackPageView(url);
// Close mobile menu
closeMobileMenu();
// Reset any modals
closeAllModals();
}, [pathname, searchParams]);
return null;
}
Confirming Navigation (Unsaved Changes)
'use client';
import { useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
export function useUnsavedChangesWarning(hasUnsavedChanges: boolean) {
const router = useRouter();
// Browser back/forward/close
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasUnsavedChanges) {
e.preventDefault();
e.returnValue = ''; // Required for Chrome
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasUnsavedChanges]);
// Wrap navigation functions
const safeNavigate = useCallback(
(href: string) => {
if (hasUnsavedChanges) {
const confirmed = window.confirm(
'You have unsaved changes. Are you sure you want to leave?'
);
if (!confirmed) return;
}
router.push(href);
},
[hasUnsavedChanges, router]
);
return { safeNavigate };
}
// Usage
function EditForm() {
const [isDirty, setIsDirty] = useState(false);
const { safeNavigate } = useUnsavedChangesWarning(isDirty);
return (
<form onChange={() => setIsDirty(true)}>
{/* form fields */}
<button type="button" onClick={() => safeNavigate('/dashboard')}>
Cancel
</button>
</form>
);
}
Key Takeaways
-
Prefetching is automatic:
<Link>prefetches visible routes. Static routes are fully prefetched; dynamic routes partially (withloading.tsx). -
loading.tsxenables streaming: Always add it to dynamic routes for immediate visual feedback and partial prefetching. -
Layouts persist: Navigation only re-renders changed segments. Leverage this for consistent UI and preserved state.
-
useRouterfor programmatic navigation: Use withuseTransitionfor non-blocking navigation with loading states. -
History API integrates seamlessly:
pushStateandreplaceStatesync with Next.js router hooks. -
useLinkStatusfor slow networks: Show loading indicators when prefetch hasn't completed before click. -
Hover-prefetch for large lists: Disable viewport prefetching, enable on hover to reduce unnecessary requests.
-
generateStaticParamsenables static generation: Dynamic segments without it fall back to dynamic rendering. -
Scroll behavior is controllable: Disable with
scroll={false}, handle sticky headers withscroll-padding-top. -
Bundle size affects hydration: Large bundles delay
<Link>prefetching. Analyze and optimize.
What did you think?
Related Posts
April 4, 202697 min
Next.js Proxy Deep Dive: Edge-First Request Interception
April 4, 2026164 min
Next.js Route Handlers Deep Dive: Building Production APIs with Web Standards
April 4, 202691 min