
Streaming solves the first page load. But what happens when users navigate between pages?
Without optimization, every click triggers a server roundtrip. Next.js eliminates this delay with prefetching and client-side transitions.
Next.js automatically prefetches routes linked with <Link> when they enter the viewport:
import Link from 'next/link'
// Prefetched when the link scrolls into view
<Link href="/blog">Blog</Link>
// Not prefetched (plain <a> tag)
<a href="/contact">Contact</a>Under the hood, Next.js uses the Intersection Observer API to detect when links enter the viewport. A task queue then schedules prefetches in priority order:
How much is prefetched depends on whether the route is static or dynamic:
| Static Route | Dynamic Route (no loading.tsx) | Dynamic Route (with loading.tsx) | |
|---|---|---|---|
| Prefetched | Full route | Nothing | Partial (layout + loading skeleton) |
| Client cache TTL | 5 min (default) | Off | 30s (configurable) |
| Server roundtrip on click | No | Yes | Yes (skeleton shown instantly) |
For static routes, the entire RSC Payload is prefetched and cached. Clicking navigates instantly—no network request at all.
For dynamic routes, the strategy is conservative: don't waste server resources pre-rendering pages the user might never visit. But if loading.tsx exists, the shared layout and skeleton are prefetched, giving immediate visual feedback while the server renders the actual content.
Traditional navigation (clicking <a href>) triggers a full page reload: DOM destroyed, state lost, scroll reset.
Next.js's <Link> component intercepts clicks and performs a client-side transition:
1. Intercept the click event
2. Fetch only the RSC Payload (not a full HTML page)
3. Reconcile the component tree in-place
4. Shared layouts survive — no re-render, no state loss
5. Scroll to top automaticallyThis is why a server-rendered Next.js app feels like a SPA. The initial visit gets server-rendered HTML; subsequent navigation gets RSC Payload-only transitions.
On initial load, the browser receives HTML + RSC Payload. On subsequent navigations, only the RSC Payload is fetched — no HTML transfer at all. The server recognizes this via the RSC: 1 request header sent by Next.js's client router:
Initial load: GET /blog/hello → HTML + RSC Payload
Subsequent nav: GET /blog/world
Headers: { RSC: 1 }
→ RSC Payload onlyThe client-side router uses this payload to update the component tree. Shared layouts remain mounted; only the changed page segment is replaced.
Disable prefetching for large lists to save bandwidth:
<Link prefetch={false} href={`/blog/${post.id}`}>
{post.title}
</Link>Hover-only prefetching — defer prefetch until the user shows intent:
'use client'
function HoverPrefetchLink({ href, children }) {
const [active, setActive] = useState(false)
return (
<Link
href={href}
prefetch={active ? null : false} // null restores default behavior
onMouseEnter={() => setActive(true)}
>
{children}
</Link>
)
}Manual prefetch via useRouter:
'use client'
import { useRouter } from 'next/navigation'
function PricingCard() {
const router = useRouter()
return <div onMouseEnter={() => router.prefetch('/pricing')}>...</div>
}On slow connections, prefetching may not complete before the user clicks. The useLinkStatus hook provides immediate feedback:
'use client'
import { useLinkStatus } from 'next/link'
export function LoadingBar() {
const { pending } = useLinkStatus()
return pending ? <div className="progress-bar" /> : null
}Add a CSS animation delay (e.g., 100ms) so the indicator only appears for genuinely slow navigations, not instant ones.
<Link> is a Client Component and can only start prefetching after hydration completes. Large JavaScript bundles delay hydration, which delays prefetching, which means early clicks might trigger full page loads.
Mitigations:
@next/bundle-analyzer to find large dependenciesloading.tsx for instant fallback during navigationuseLinkStatus provides feedback for slow-network transitions.Previous:
Next: