Concept
The beginner framing: <Link> is how you navigate between routes in a Next.js app, it looks like an <a> tag, but it does considerably more work behind the scenes to keep navigation fast.
The precise mental model: every navigation is fundamentally one of two kinds. A soft navigation (triggered by <Link> or the router) fetches only the RSC payload for the segment that actually changed, keeps every shared layout mounted and interactive, and preserves scroll and client state. A hard navigation (a full browser reload, or a plain <a> tag) discards everything and starts over from scratch, every layout remounts, all client state is lost.
import Link from "next/link";
<nav>
<Link href="/blog">Blog</Link> {/* prefetched, soft navigation */}
<a href="/contact">Contact</a> {/* NOT prefetched, hard navigation */}
</nav><Link href="/blog">Blog</Link>// enters the viewport...
Next.js automatically prefetches routes linked with <Link> as they enter the viewport, for a static route, the full route; for a dynamic one, a partial prefetch if loading.tsx exists.
Prefetching: loading a route before it's clicked
Next.js automatically prefetches <Link> routes as they enter the viewport. How much gets prefetched depends on the route:
- Static routes: the full route is prefetched.
- Dynamic routes: prefetching is skipped entirely, unless a
loading.tsxexists for that segment, in which case the shared layout and the loading skeleton are prefetched (a partial prefetch), so navigation still feels instant even though the real dynamic content isn't ready yet.
Streaming: sending what's ready, without waiting for everything
loading.tsx (see Loading & Error UI) is what enables streaming for a route segment, Next.js automatically wraps the page in a <Suspense> boundary, showing the prefetched fallback immediately and swapping in the real content once rendering finishes.
Route groups: organizing folders without affecting the URL
app/
(marketing)/
about/page.tsx → /about (NOT /marketing/about)
pricing/page.tsx → /pricing
(shop)/
layout.tsx → a DIFFERENT layout, only for shop routes
products/page.tsx → /productsWrapping a folder name in parentheses, (marketing), creates a route group: it organizes files and can apply a distinct layout to a subset of routes, but the parentheses themselves never appear in the URL.
Try It
Predict what happens before checking the solution.
app/
dashboard/
layout.tsx (has NO loading.tsx sibling)
page.tsx (fetches data, a dynamic route)A user hovers over a <Link href="/dashboard"> for a while before clicking. Does the click feel instant?
Solution
No, not fully. Since app/dashboard/ is a dynamic route (it fetches data) and has no loading.tsx, Next.js skips prefetching it entirely, hovering does nothing. When the user finally clicks, the client must wait for the full server response before showing anything, which can feel like the app isn't responding. Adding a loading.tsx to that folder would enable a partial prefetch (the shared layout + a loading skeleton), making the click feel instant even though the real data still needs to load in afterward.
Implement It Yourself
Model the soft-vs-hard navigation decision and prefetch behavior:
function planNavigation(route, { hasLoadingFile, isStatic }) {
if (route.startsWith("http") || route.isFullReload) {
return { kind: "hard", prefetch: "none", description: "full document reload, everything remounts" };
}
if (isStatic) {
return { kind: "soft", prefetch: "full", description: "entire route prefetched, instant on click" };
}
if (hasLoadingFile) {
return { kind: "soft", prefetch: "partial", description: "shared layout + skeleton prefetched, real content streams in after click" };
}
return { kind: "soft", prefetch:
This is the exact decision tree covered above: navigation type first (soft vs. hard), then, for soft navigations, how much gets prefetched ahead of time based on whether the route is static and whether loading.tsx exists.
Under the Hood
A soft navigation's ability to keep shared layouts mounted is the exact same "layouts don't re-render on navigation" guarantee from App Router Overview, nothing new is happening mechanically, it's the same nested-component-tree behavior, just triggered by <Link> instead of a raw route change. And streaming a dynamic route's content in after the shell is the identical mechanism from Suspense, loading.tsx is just Next.js automatically wiring up the <Suspense> boundary for you at the route-segment level.
Common Mistakes
1. Using a plain <a> tag for internal navigation
<a href="/dashboard">Dashboard</a> {/* ❌ forces a hard navigation */}This forfeits prefetching, soft navigation, and layout preservation entirely, always use <Link> for navigation within the app; reserve plain <a> tags for genuinely external URLs.
2. Expecting a dynamic route to feel instant without loading.tsx
Covered in Try It, without a loading.tsx, a dynamic route isn't prefetched at all, so every click waits for the full server round-trip. This is one of the most common causes of "navigation feels slow" reports.
3. Assuming route groups affect the URL
app/(admin)/settings/page.tsx → the URL is STILL /settings, not /admin/settingsParentheses around a folder name are purely an organizational and layout-scoping tool, they never appear in, or affect, the resulting URL.
Best Practices
- Always use
<Link>for internal navigation, reserve raw<a>tags for external links where a full navigation is actually correct. - Add
loading.tsxto any dynamic route where instant-feeling navigation matters, it's the single highest-leverage fix for "clicking a link feels slow." - Use route groups to apply different layouts to different sections of the app without duplicating URL structure or forcing an artificial path segment.
- Disable prefetching (
prefetch={false}) for very large lists of links (e.g. an infinite-scroll table) to avoid wasted prefetch requests for links the user is unlikely to click.
Performance Tips
- Prefetching trades a small amount of background network usage for a large perceived-speed win, the default (prefetch on viewport entry) is right for most navigation; only override it for pages with unusually many links.
useLinkStatuslets you show a debounced loading indicator (e.g., after a 100ms delay) specifically for slow-network cases where prefetching didn't finish before the click, a small UX addition that prevents navigation from feeling broken on a bad connection.- Client Components need to hydrate before
<Link>can start prefetching, a large initial JS bundle delays this. Keeping the initial bundle lean (see Server/Client Component Composition) has a knock-on benefit for how soon prefetching can even begin.
