
The Next.js Link Component: Prefetching, Scroll Behavior, and Best Practices
Link is probably the component you use most in a Next.js app, and the one you think about least. You import it from next/link, give it an href, and navigation feels instant. That speed isn't magic. It comes from prefetching, client-side transitions, and a router that keeps shared layouts mounted between pages.
Knowing how those pieces work pays off once your app grows. A page with hundreds of links can flood the network with prefetches. A dynamic route can feel sluggish because nothing was prefetched. A sticky header can hide the heading you just scrolled to. And an active nav link needs a small Client Component that's easy to get wrong.
This guide covers what Link renders, how prefetching decides what to load, how scroll behavior works, every App Router prop worth knowing, and the patterns I use in production.
What Link Actually Renders
Link renders a real <a> element. That matters for three reasons:
- It works without JavaScript. Before hydration, or if JavaScript fails, clicking the link performs a normal full-page navigation.
- It's accessible by default. Screen readers announce it as a link, and keyboard users can tab to it and press Enter.
- Browser features work. Cmd/Ctrl+click opens a new tab, right-click offers "Copy link address", and middle-click works as expected.
Any standard anchor attribute, like className, target, rel, aria-current, or id, can be passed straight to Link and lands on the <a>.
// app/ui/footer.tsx
import Link from "next/link";
export function Footer() {
return (
<footer className="flex gap-6 text-sm">
<Link href="/about" className="hover:underline">
About
</Link>
<Link href="/blog" className="hover:underline">
Blog
</Link>
<Link
href="https://github.com/vercel/next.js"
target="_blank"
rel="noopener noreferrer"
>
Next.js on GitHub
</Link>
</footer>
);
}
Link itself is a Client Component, but you don't need "use client" to use it. You can render it directly from Server Components, as above. Only the link hydrates; the footer stays server-rendered.
When the user clicks an internal link with JavaScript loaded, Link intercepts the click and performs a client-side transition: it fetches the React Server Component payload for the new route, reuses any layouts the two routes share, and swaps in the new page without a full reload.
The href Prop
href accepts either a string or a URL object.
For dynamic routes, template literals are the simplest option:
// app/blog/post-list.tsx
import Link from "next/link";
type Post = { id: number; slug: string; title: string };
export function PostList({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</li>
))}
</ul>
);
}
When you have query parameters, an object avoids manual string escaping:
<Link href={{ pathname: "/search", query: { q: "server components", page: 2 } }}>
Next page
</Link>
That renders /search?q=server+components&page=2, with the encoding handled for you.
Typed Routes
If you use TypeScript, turn on typedRoutes in your config. Next.js then generates types for every route in your app, and Link will flag a typo like /blgo at compile time:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
typedRoutes: true,
};
export default nextConfig;
This option is stable in Next.js 16, so it lives at the top level rather than under experimental. For dynamic segments, template literals still type-check as long as the static parts match a real route. More on the TypeScript setup in how to use TypeScript with Next.js.
How Prefetching Works
Prefetching is the reason navigation feels instant. When a Link enters the viewport, Next.js loads the route behind it in the background. By the time the user clicks, the data is already in the client cache.
A few facts that explain most prefetching behavior:
- It only happens in production. In
next dev, nothing is prefetched, so navigation in development is slower than what users will see. Always judge navigation speed fromnext build && next start. - It's scheduled, not instant. Next.js keeps a small queue. Links in the viewport go first, then links the user hovers or touches. Newer links replace older ones, and links that scroll out of view are dropped.
- It needs hydration.
Linkmust hydrate before it can prefetch, so a heavy JavaScript bundle on first load delays prefetching too. Keeping client bundles small helps here.
Static vs. Dynamic Routes
How much gets prefetched depends on the route:
| Route type | What's prefetched by default | On click |
|---|---|---|
| Static | The full route, including data | Renders from cache, no server round trip |
Dynamic, no loading.tsx | Nothing | Waits for the server before navigating |
Dynamic, with loading.tsx | Shared layouts down to the loading boundary | Shows the loading UI immediately, then streams the page |
That last row is the most useful thing in this post. If a dynamic route feels slow to navigate to, add a loading.tsx. Navigation becomes immediate: the user sees the layout and skeleton right away, and the real content streams in.
// app/orders/[id]/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 w-1/3 rounded bg-gray-200" />
<div className="h-40 rounded bg-gray-200" />
</div>
);
}
The prefetch Prop
You can override the default per link:
"auto"ornull(the default): prefetch based on the route type, as in the table above.true: prefetch the full route, even if it's dynamic. Useful for a primary call-to-action you're confident the user will click.false: never prefetch, either on viewport entry or hover.
<Link href="/checkout" prefetch={true}>
Go to checkout
</Link>
<Link href={`/archive/${year}`} prefetch={false}>
{year}
</Link>
Use prefetch={true} sparingly. Prefetching a dynamic route fully means rendering it on your server, even if nobody clicks. On a page with many such links, that's a lot of wasted work.
If your app has Cache Components and the partialPrefetching option enabled, the default changes: Link prefetches a per-route App Shell (the static and cached parts) shared by every link to that route, and uncached data streams in after navigation. Check the Next.js prefetching guide if you've adopted it.
Prefetch on Hover Instead
For long lists, like a table with 200 rows each linking to a detail page, viewport prefetching may fetch far more than anyone will use. A middle ground is to prefetch only when the user shows intent:
// app/ui/hover-prefetch-link.tsx
"use client";
import Link from "next/link";
import { useState } from "react";
export 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>
);
}
The link starts with prefetching off. The first hover switches it to null, which restores default prefetching, so the route loads in the moment between hover and click. It's usually enough time for static routes.
Prefetching Can Run Your Code
Because prefetching renders routes ahead of time, any side effect in a layout or page body runs during prefetch, not when the user actually visits. The classic bug is page-view analytics called directly in a Server Component:
// Don't do this: runs on prefetch
export default function Layout({ children }: { children: React.ReactNode }) {
trackPageView();
return <div>{children}</div>;
}
Keep rendering pure and move side effects into a useEffect in a Client Component, which only runs when the page actually mounts.
Scroll Behavior
By default, Link tries to behave like the browser would for a normal page change, with a twist that keeps shared layouts stable:
- If the new page is already visible in the viewport, Next.js keeps the current scroll position.
- If it's not visible, Next.js scrolls to the top of the first element of the new page segment, not necessarily the top of the document.
That second detail is why a navigation inside a dashboard with a fixed sidebar doesn't jump the whole window. Next.js looks for the page content and brings it into view.
Disabling Scroll
Pass scroll={false} to stop Next.js from scrolling at all:
<Link href="?tab=reviews" scroll={false}>
Reviews
</Link>
This is the right choice for tabs, filters, and pagination controls that only change part of the page. The user clicked something mid-page and expects to stay there.
Hash Links
Since Link renders an <a>, hash fragments work normally. Link to /docs/install#requirements and the browser scrolls to the element with id="requirements" once the page is shown.
Sticky Headers
When Next.js finds a scroll target, it skips sticky and fixed elements. The result is that your heading can end up hidden underneath a sticky header. Fix it in CSS rather than JavaScript:
/* app/globals.css */
html {
scroll-padding-top: 4rem; /* match your sticky header height */
}
scroll-padding-top offsets every scrollIntoView call, including the ones Next.js uses and hash navigation. If only some elements need the offset, use scroll-margin-top on those instead.
Replace Instead of Push
By default, a link click adds a new history entry. The replace prop swaps the current entry instead:
<Link href="/onboarding/step-3" replace>
Continue
</Link>
Use it when going back to the previous URL wouldn't make sense: steps in a flow, toggling a view mode in the query string, or switching between sort orders. For everything else, the default push behavior is what users expect.
Active Links
Link doesn't know whether it points at the current page. To style the active item, read the pathname with usePathname in a small Client Component:
// app/ui/nav-link.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export function NavLink({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const pathname = usePathname();
const isActive =
href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
href={href}
aria-current={isActive ? "page" : undefined}
className={isActive ? "font-semibold text-black" : "text-gray-500"}
>
{children}
</Link>
);
}
Two details matter here. The home link needs an exact match, otherwise every path "starts with" /. And aria-current="page" tells assistive technology which link is current, which a color change alone doesn't.
Use NavLink inside a server-rendered layout and only those links become interactive client code. The rest of the navigation can stay on the server.
Showing Pending State with useLinkStatus
When a navigation can't complete instantly (prefetching was disabled, or a dynamic route has no loading.tsx), the user may click and see nothing happen for a moment. useLinkStatus from next/link exposes a pending flag for the link it's rendered inside:
// app/ui/link-hint.tsx
"use client";
import { useLinkStatus } from "next/link";
export function LinkHint() {
const { pending } = useLinkStatus();
return (
<span
aria-hidden
className={`ml-1 inline-block h-2 w-2 rounded-full bg-current transition-opacity ${
pending ? "opacity-100 animate-pulse" : "opacity-0"
}`}
/>
);
}
// app/ui/header.tsx
import Link from "next/link";
import { LinkHint } from "./link-hint";
export function Header() {
return (
<nav className="flex gap-4">
<Link href="/reports" prefetch={false}>
Reports <LinkHint />
</Link>
</nav>
);
}
The hook must be called in a component rendered as a descendant of Link. The hint is always rendered at a fixed size and only its opacity changes, which avoids layout shift. Treat this as a patch: a loading.tsx or better prefetching usually fixes the root cause.
Intercepting Navigation with onNavigate
onNavigate runs only for client-side navigations, and its event has a preventDefault() method. Unlike onClick, it doesn't fire for Cmd/Ctrl+click (new tab), external URLs, or download links. That makes it the right hook for "you have unsaved changes" prompts:
// app/ui/guarded-link.tsx
"use client";
import Link from "next/link";
export function GuardedLink({
href,
hasUnsavedChanges,
children,
}: {
href: string;
hasUnsavedChanges: boolean;
children: React.ReactNode;
}) {
return (
<Link
href={href}
onNavigate={(event) => {
if (
hasUnsavedChanges &&
!window.confirm("You have unsaved changes. Leave anyway?")
) {
event.preventDefault();
}
}}
>
{children}
</Link>
);
}
For app-wide blocking, put the "is dirty" flag in a React context and read it from a shared link component. Remember that this only covers in-app links. Closing the tab or typing a URL needs a beforeunload listener as well.
Link vs. useRouter vs. a Plain Anchor
| Situation | Use |
|---|---|
| Anything the user clicks to go to another page in your app | Link |
| Navigation after an event that isn't a click on a link (form success, keyboard shortcut) | useRouter().push |
External sites, mailto:, tel:, file downloads | Plain <a> |
| Navigation decided on the server | redirect() |
Don't build clickable divs or buttons that call router.push for ordinary navigation. You lose prefetching, Cmd+click, the context menu, and accessibility. For the non-link cases, see programmatic navigation with useRouter and redirect.
Best Practices Checklist
- Use
Linkfor all internal navigation, rendered directly from Server Components where possible. - Add
loading.tsxto dynamic routes that users navigate to often. - Use
prefetch={false}or hover prefetching for large lists of links. - Reserve
prefetch={true}for a few high-intent links. - Use
scroll={false}for tabs, filters, and pagination that update part of the page. - Use
replacefor flows and view toggles that shouldn't fill up history. - Fix sticky header overlap with
scroll-padding-top. - Keep layouts and pages free of side effects so prefetching doesn't trigger them.
- Test navigation speed against a production build, not
next dev.
Conclusion
Link gives you a real anchor tag with client-side transitions and smart prefetching built in. Most of the time the defaults are right. When they're not, the fixes are small: a loading.tsx for slow dynamic routes, prefetch={false} or hover prefetching for big lists, scroll={false} for in-page controls, and a bit of CSS for sticky headers. Get those right and navigation in your app will feel as fast as the framework promises.


