
Next.js Linking and Navigating
Every route in the Next.js App Router is rendered on the server by default. That single fact explains almost everything interesting about how navigation behaves in this framework, and it's also the reason navigation can feel slow if you don't understand the machinery Next.js puts in place to compensate. When a page is server-rendered, the browser has to wait for a response before it can show you anything new. Do that on every click and your app feels like the web circa 2010 — full page loads, blank white flashes, lost scroll position.
Next.js doesn't work that way in practice, because it layers a handful of client-side optimizations on top of server rendering: prefetching, streaming, and client-side transitions. Together they make a server-rendered app feel like a single-page app, without you writing a line of routing code. But "automatic" doesn't mean "invisible" — once you understand what's actually happening under a <Link> click, you can diagnose slow navigations, choose the right escape hatches, and stop fighting the framework's defaults.
This article walks through the full navigation pipeline: what happens between a link entering the viewport and the new page appearing, why some routes prefetch instantly while others don't prefetch at all, and what to do when a navigation still feels sluggish despite all of this machinery working as intended.
Why Server Rendering Creates a Navigation Problem in the First Place
In the App Router, layouts and pages are React Server Components by default. That means the component tree for a route is executed on the server, turned into a special data format called the Server Component Payload, and streamed down to the client — both on the very first visit and on every subsequent navigation. There is no build step that turns your whole app into static client-side JavaScript the way a traditional SPA would; the server stays in the loop for the lifetime of the app.
Server rendering itself splits into two flavors, and which one applies to a given route matters a lot for navigation performance:
Prerendering happens ahead of time — at build time, or later during revalidation — and the resulting payload is cached. Visiting a prerendered route is just a cache read; there's no per-request work happening on the server.
Dynamic rendering happens at request time, in direct response to the click that triggered the navigation. The server has to actually do work — run your page component, fetch data, render the tree — before it can respond.
If Next.js did nothing else, every navigation to a dynamic route would mean sitting and staring at the current page until the server finishes that work. That's the problem prefetching, streaming, and client-side transitions solve, and they solve it as a set — none of them is very useful in isolation.
Prefetching: Doing the Work Before You Ask
Prefetching means loading a route's data in the background, before the user actually clicks the link that goes there. The bet Next.js is making is a good one: users tend to hover over or scroll past a link before they click it, and that's a window of a few hundred milliseconds you can use productively.
The mechanism is tied directly to the <Link> component from next/link. Any <Link> that scrolls into the viewport gets automatically prefetched — no configuration required.
// app/layout.tsx
import Link from "next/link";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<nav>
{/* Prefetched automatically once this link enters the viewport */}
<Link href="/blog">Blog</Link>
{/* A plain anchor tag — Next.js has no idea this exists */}
<a href="/contact">Contact</a>
</nav>
{children}
</body>
</html>
);
}
That second point is worth dwelling on, because it's a mistake I still see in real codebases: mixing plain <a> tags with <Link> inside the same navigation. A plain anchor gets zero prefetching, zero client-side transition, and forces a full page reload on click. If you're building internal navigation and reaching for <a> instead of <Link> — maybe out of habit from static HTML, maybe because a UI library's as prop defaults to it — you're silently opting out of everything this article is about. Grep your codebase for <a href="/ inside your own components and you'll usually find a few of these hiding in older code.
How much of a route actually gets prefetched depends on whether the route is static or dynamic:
- A static route gets prefetched in full. The entire payload is sitting in the client's cache by the time you click.
- A dynamic route either skips prefetching entirely, or gets partially prefetched if the route has a
loading.tsxfile.
This distinction is deliberate, not a limitation. If Next.js eagerly prefetched every dynamic route a user's cursor drifted near, you'd be hammering your server (and your database, and any third-party APIs your data layer touches) for pages the user has maybe a 10% chance of actually visiting. Skipping full prefetches for dynamic routes protects your backend from that load. The tradeoff is that a fully skipped dynamic prefetch means the user has to wait for the actual server response after they click — which can read as the app "not responding." That's exactly what loading.tsx and streaming exist to fix.
Streaming: Sending What's Ready, When It's Ready
Streaming lets the server send parts of a route to the client as soon as those parts are ready, instead of holding everything back until the entire tree has finished rendering. In practice this means a dynamic route can ship its shared layout and a loading skeleton almost immediately, while the actual data-dependent content streams in behind it a moment later.
You opt into this by adding a loading.tsx file next to your page.tsx:
// app/dashboard/loading.tsx
export default function Loading() {
// Shown while the route segment is loading
return <LoadingSkeleton />;
}
Behind the scenes, Next.js wraps your page.tsx in a React <Suspense> boundary automatically, using loading.tsx as the fallback. You don't write any Suspense code yourself for this to work at the route level — it's implicit in the file convention. If you want more granular loading states for individual pieces of a page rather than the whole route, you reach for <Suspense> directly around specific components, which lets one slow data-dependent widget stream in without blocking the rest of the page.
The concrete benefits of adding loading.tsx to a dynamic route:
- Immediate navigation and visual feedback. The user sees something — a skeleton, a spinner, whatever you build — the instant they click, instead of a frozen previous page.
- Interruptible navigation with shared layouts staying interactive. Because the layout renders separately from the page content, the user can keep interacting with nav bars, sidebars, and other persistent chrome while the page content is still loading.
- Better Core Web Vitals — specifically Time to First Byte, First Contentful Paint, and Time to Interactive, because you're no longer gating the entire response on the slowest piece of data.
This is one of those Next.js conventions that looks almost too simple in isolation — "just add a loading.tsx file" — but the effect on perceived performance for anything doing real data fetching (a dashboard, a user profile page, a search results page) is large enough that I'd treat it as close to mandatory for any dynamic route in production. It costs you one small file and buys you a genuinely better navigation experience.
Client-Side Transitions: Not a Full Page Reload
Traditionally, navigating to a server-rendered page meant a full browser navigation: the whole document gets torn down and rebuilt, React state resets, scroll position resets, and the page is briefly non-interactive while everything reloads. That's how navigating between two completely separate server-rendered HTML documents has always worked, and it's still what happens if you click a plain <a> tag.
<Link> avoids all of that. Instead of a full reload, it performs a client-side transition: it keeps whatever layouts are shared between the old and new route, and swaps in either the prefetched loading state or the new page content, depending on what's already available. The DOM isn't torn down and rebuilt from scratch — React reconciles the difference.
This is the piece that actually makes a server-rendered Next.js app feel like a client-rendered single-page app to the end user, and it's the payoff for everything prefetching and streaming set up. Combined, the three form a pipeline: prefetch the data ahead of time, stream in whatever isn't ready yet, then transition to it client-side without a hard reload.
Next.js also automatically handles scrolling to the top of the page during these transitions, which matters more than it sounds — without it, navigating from the bottom of a long article to a fresh page would leave the user staring at whatever happened to be at that same scroll depth on the new page. One wrinkle worth knowing about: if your app has a sticky or fixed header, content can end up scrolling in behind it after a client-side navigation. The fix isn't a JavaScript workaround — it's a one-line CSS property:
html {
scroll-padding-top: 80px; /* match your fixed header's height */
}
scroll-padding-top tells the browser to treat that many pixels at the top of the scrolling area as "reserved," so scroll targets land below your fixed header instead of underneath it. It's a small thing, but I've seen teams reach for scroll-position JavaScript hacks to solve this when a single CSS rule handles it.
Why Transitions Still Feel Slow Sometimes
Everything above describes the happy path. In the real world, a few specific situations undercut these optimizations, and it's worth knowing what they are so you can recognize them instead of assuming Next.js navigation is "just slow" for that route.
Dynamic Routes With No loading.tsx
This is the single most common cause of sluggish-feeling navigation, and it's entirely self-inflicted. If a route is dynamic and has no loading.tsx, Next.js has nothing to show while the server does its work — the click just sits there, and the previous page stays frozen on screen until the response comes back. The user has no way to tell whether their click registered.
The fix is exactly what was covered above: add a loading.tsx file to the dynamic route's folder.
// app/blog/[slug]/loading.tsx
export default function Loading() {
return <LoadingSkeleton />;
}
If you're not sure whether a given route is being rendered statically or dynamically in development, Next.js Devtools can tell you directly, and it's worth checking rather than guessing — a route you assumed was static because it "looks simple" can quietly become dynamic the moment you add a cookies() call, an uncached fetch, or a search param read inside it.
Dynamic Segments Missing generateStaticParams
A dynamic segment — something like app/blog/[slug]/page.tsx — could be prerendered at build time for every known value of slug, but only if you tell Next.js what those values are. Without a generateStaticParams function, Next.js has no way to know which slugs exist ahead of time, so it falls back to rendering the page dynamically on every request, even for pages whose content never changes.
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetch("https://.../posts").then((res) => res.json());
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// ...
}
This is worth checking any time a "static-feeling" content page is navigating slower than you'd expect. Blog posts, product pages, documentation pages — anything with a fixed, enumerable set of values behind a dynamic segment is a candidate for generateStaticParams. Skipping it doesn't just cost you navigation speed; it means you're doing full server rendering work on every single visit to content that was fully knowable at build time.
Slow or Unstable Networks
Prefetching assumes there's enough time between a link entering the viewport (or being hovered) and the actual click for the background request to complete. On a slow or flaky connection, that assumption can simply be wrong — the user clicks before prefetching finishes, for both static and dynamic routes, and the loading.tsx fallback itself hasn't been prefetched yet either, so even that safety net is delayed.
The tool for this situation is the useLinkStatus hook, which tells you whether a navigation triggered by a specific <Link> is still pending, so you can show your own immediate feedback rather than relying on the destination page to announce it's loading:
// app/ui/loading-indicator.tsx
"use client";
import { useLinkStatus } from "next/link";
export default function LoadingIndicator() {
const { pending } = useLinkStatus();
return (
<span aria-hidden className={`link-hint ${pending ? "is-pending" : ""}`} />
);
}
A detail that's easy to miss on a first read: don't just show this indicator unconditionally the moment pending flips to true. On a fast connection, most navigations resolve in well under 100ms, and a spinner that flashes for a fraction of a second reads as visual noise rather than useful feedback — it can actually make an app feel less polished. The standard fix is to debounce the indicator with a short CSS animation delay and start it invisible, so it only becomes visible if the navigation genuinely takes longer than, say, 100ms:
.link-hint {
opacity: 0;
animation: fade-in 0.1s 0.1s forwards;
}
@keyframes fade-in {
to {
opacity: 1;
}
}
If offline resilience matters for your app specifically, there's also an experimental useOffline configuration option that can keep already-prefetched routes navigable even when connectivity drops entirely — worth a look if you're building something meant to survive spotty mobile connections, though being experimental, I'd treat it as something to evaluate rather than depend on for a production launch just yet.
Deliberately Disabling Prefetching
Sometimes prefetching is the wrong default, not a bug to work around. The clearest case is a long or infinite list of links — a table with hundreds of rows, an infinite-scroll feed — where prefetching every visible link would mean firing off dozens or hundreds of background requests for pages the user will almost certainly never visit. That's wasted bandwidth on the client and wasted load on your server for no real benefit.
You opt out per-link with the prefetch prop:
<Link prefetch={false} href="/blog">
Blog
</Link>
Disabling prefetching isn't free, though — it means static routes only fetch on click, and dynamic routes wait for the full server round-trip on click, which is exactly the slow-feeling behavior all of this machinery exists to avoid. So don't reach for prefetch={false} as a blanket default; it's a targeted tool for specific situations, not a performance "safe mode."
A better middle ground for large lists is prefetching only on hover, which limits background requests to links the user has shown active interest in rather than every link that happens to scroll past:
// app/ui/hover-prefetch-link.tsx
"use client";
import Link from "next/link";
import { useState } from "react";
function HoverPrefetchLink({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const [active, setActive] = useState(false);
return (
<Link
href={href}
prefetch={active ? null : false}
onMouseEnter={() => setActive(true)}
>
{children}
</Link>
);
}
Passing null instead of omitting the prop restores Next.js's default prefetching behavior once the link has been hovered, rather than forcing a specific prefetch strategy — worth noting since it's easy to assume true/false are the only two states this prop accepts.
Hydration That Hasn't Finished Yet
<Link> is a Client Component, and prefetching depends on JavaScript having hydrated on the client. On a first visit with a large JavaScript bundle, hydration itself can take long enough that prefetching simply hasn't started yet by the time a user tries to interact with a link.
React's Selective Hydration helps here by prioritizing hydration of the parts of the page the user is actually interacting with, but you can go further by actually shrinking what needs to hydrate in the first place. Two concrete levers:
- Run
@next/bundle-analyzeragainst your production build and look for dependencies that are larger than they have any right to be — a charting library imported in full when you only use one chart type, a date library pulled in wholesale instead of per-function. - Push logic that doesn't need interactivity back into Server Components. Every component you keep server-only is JavaScript that never has to ship to the browser or hydrate at all — it's not just faster, it's a smaller bundle for every other Client Component competing for hydration time on that page.
If your app feels sluggish specifically on cold, first-time visits but fine on subsequent navigations within the same session, hydration cost is the first thing I'd suspect, not the navigation system itself.
Reaching Below Link: The Native History API
Everything so far assumes you're navigating through <Link> clicks. But sometimes you want to change the URL without the user clicking anything — updating a sort order in the query string, switching a locale, syncing some piece of client state into the URL so it survives a refresh or a shared link. For that, Next.js lets you use the browser's native History API directly, and it's wired up to stay in sync with the App Router's own hooks like usePathname and useSearchParams.
window.history.pushState
pushState adds a new entry to the browser's history stack, meaning the user can navigate back to the state before the change. It's the right choice whenever the change represents something the user would reasonably expect a "back" button press to undo — reordering a list is a good example:
// app/ui/sort-products.tsx
"use client";
import { useSearchParams } from "next/navigation";
export default function SortProducts() {
const searchParams = useSearchParams();
function updateSorting(sortOrder: string) {
const params = new URLSearchParams(searchParams.toString());
params.set("sort", sortOrder);
window.history.pushState(null, "", `?${params.toString()}`);
}
return (
<>
<button onClick={() => updateSorting("asc")}>Sort Ascending</button>
<button onClick={() => updateSorting("desc")}>Sort Descending</button>
</>
);
}
Notice this doesn't call router.push from next/navigation — it's talking straight to the browser API. Next.js's router still picks up the change and keeps useSearchParams in sync, which is what makes this safe to mix with the rest of the App Router instead of fighting it.
window.history.replaceState
replaceState swaps out the current history entry instead of adding a new one, so there's no new "back" step created. This is the right call when the change isn't something the user thinks of as a distinct navigation step — switching a display locale is the canonical example, since going "back" to the previous locale isn't really a meaningful user action:
// app/ui/locale-switcher.tsx
"use client";
import { usePathname } from "next/navigation";
export function LocaleSwitcher() {
const pathname = usePathname();
function switchLocale(locale: string) {
const newPath = `/${locale}${pathname}`;
window.history.replaceState(null, "", newPath);
}
return (
<>
<button onClick={() => switchLocale("en")}>English</button>
<button onClick={() => switchLocale("fr")}>French</button>
</>
);
}
The distinction between these two APIs is really a UX question dressed up as a technical one: does hitting the back button after this change make sense to a user? If yes, pushState. If the change is more like a preference toggle than a navigation, replaceState. Getting this backwards is a subtle but real usability bug — a "sort ascending / sort descending" toggle implemented with replaceState means users can never navigate back through their sort history, while a locale switcher implemented with pushState fills up the back button history with what amounts to noise.
One thing worth flagging clearly: neither of these calls a full navigation. They update the URL and let the router's hooks reflect the new value, but they don't re-render a different route tree the way a <Link> click or router.push would. If you need an actual route change — not just a URL cosmetic update — reach for the useRouter hook's push/replace methods instead of the raw History API.
Key Takeaways
Next.js navigation is built from four cooperating pieces, and understanding each one tells you exactly where to look when something feels off:
| Symptom | Likely Cause | Fix |
|---|---|---|
| Click on a link does nothing visible for a moment | Dynamic route with no loading.tsx | Add loading.tsx to enable partial prefetch and immediate feedback |
| A "static" content page renders slowly on every visit | Dynamic segment missing generateStaticParams | Add generateStaticParams so the page prerenders at build time |
| Navigation is inconsistent on mobile or poor connections | Prefetch didn't finish before the click | Use useLinkStatus with a debounced loading indicator |
| Large list of links feels heavy or over-fetches | Every visible <Link> prefetching by default | Disable prefetch globally, or prefetch on hover only |
| First visit feels slow, later navigations feel fine | Hydration delay from a large JS bundle | Analyze the bundle, push more logic into Server Components |
| Content scrolls in behind a fixed header after navigating | Missing scroll offset for sticky headers | Add scroll-padding-top matching your header height |
The underlying idea worth carrying away from all of this: Next.js isn't hiding server rendering from you, it's making server rendering fast enough that you don't need to reach for a client-rendered SPA architecture just to get instant-feeling navigation. <Link>, loading.tsx, and streaming are three separate, composable levers — and once you know which one addresses which symptom, "navigation feels slow" stops being a mystery and starts being a checklist.


