Back to Blog

Next.js Linking and Navigation: Complete Architecture Guide

March 26, 2026105 min read0 views

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.

┌─────────────────────────────────────────────────────────────────────┐
│                    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 TypePrefetch BehaviorWhat's Cached
StaticFull prefetchComplete RSC payload
Dynamic (no loading.tsx)SkippedNothing
Dynamic (with loading.tsx)Partial prefetchLayouts + 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
      */}
    </>
  );
}
// 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>
  );
}

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>
  );
}

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;
}

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

  1. Prefetching is automatic: <Link> prefetches visible routes. Static routes are fully prefetched; dynamic routes partially (with loading.tsx).

  2. loading.tsx enables streaming: Always add it to dynamic routes for immediate visual feedback and partial prefetching.

  3. Layouts persist: Navigation only re-renders changed segments. Leverage this for consistent UI and preserved state.

  4. useRouter for programmatic navigation: Use with useTransition for non-blocking navigation with loading states.

  5. History API integrates seamlessly: pushState and replaceState sync with Next.js router hooks.

  6. useLinkStatus for slow networks: Show loading indicators when prefetch hasn't completed before click.

  7. Hover-prefetch for large lists: Disable viewport prefetching, enable on hover to reduce unnecessary requests.

  8. generateStaticParams enables static generation: Dynamic segments without it fall back to dynamic rendering.

  9. Scroll behavior is controllable: Disable with scroll={false}, handle sticky headers with scroll-padding-top.

  10. Bundle size affects hydration: Large bundles delay <Link> prefetching. Analyze and optimize.

What did you think?

Related Posts

© 2026 Vidhya Sagar Thakur. All rights reserved.