Type something to search...
The Next.js Link Component: Prefetching, Scroll Behavior, and Best Practices

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 from next 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. Link must 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 typeWhat's prefetched by defaultOn click
StaticThe full route, including dataRenders from cache, no server round trip
Dynamic, no loading.tsxNothingWaits for the server before navigating
Dynamic, with loading.tsxShared layouts down to the loading boundaryShows 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" or null (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

SituationUse
Anything the user clicks to go to another page in your appLink
Navigation after an event that isn't a click on a link (form success, keyboard shortcut)useRouter().push
External sites, mailto:, tel:, file downloadsPlain <a>
Navigation decided on the serverredirect()

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 Link for all internal navigation, rendered directly from Server Components where possible.
  • Add loading.tsx to 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 replace for 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.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading