Type something to search...
Next.js Link Component

Next.js Link Component

Every Next.js app eventually comes down to one question: how does a visitor get from page A to page B without the whole browser reloading? The answer is next/link, and while most tutorials show you the two-line version — wrap some text in a <Link href="..."> and move on — the component actually carries a surprising amount of configurable behavior once you dig into its full prop surface. Scroll restoration, prefetch strategy, view transitions, navigation blocking, and Proxy-aware rewrites are all controlled from this one component, and getting the defaults wrong is a common source of subtle UX bugs: a page that jumps to the top when it shouldn't, a prefetch storm on a page with hundreds of links, or a "leave without saving?" prompt that never fires.

This article is the full reference for <Link> — every prop, what it actually does under the hood, and the situations where the defaults will surprise you. If you want the conceptual "how does client-side navigation work" explainer instead, that's a separate piece; this one assumes you already know why you'd reach for <Link> and focuses entirely on how to control it.

The one-line version, for context

At its simplest, <Link> behaves like an anchor tag that Next.js has wired up for client-side transitions:

import Link from "next/link";

export default function Page() {
  return <Link href="/dashboard">Dashboard</Link>;
}

That's genuinely all you need for the overwhelming majority of links in an app. Everything below this point is about the remaining 10% of cases — active-link styling, scroll control, prefetch tuning, and navigation guards — where the default behavior isn't quite what you want.

One thing worth internalizing up front: <Link> renders down to a real <a> element. Any prop that isn't one of the Next.js-specific ones documented below — className, target="_blank", aria-* attributes, rel — just passes straight through to that underlying anchor. You don't need a separate wrapper component to add standard HTML attributes.

href — the only required prop

href accepts either a plain string or an object with pathname and query fields. The object form is genuinely useful once you have query parameters to manage, since it saves you from hand-building a query string:

import Link from "next/link";

export default function AboutLink() {
  return (
    <Link href={{ pathname: "/about", query: { ref: "footer" } }}>About</Link>
  );
}

That resolves to /about?ref=footer. It's a small thing, but it keeps query-string construction out of your JSX and avoids the classic bug of forgetting to URL-encode a value.

replace — swap history instead of pushing to it

By default, clicking a <Link> pushes a new entry onto the browser's history stack, same as a normal anchor tag. Set replace to swap the current entry instead:

<Link href="/dashboard" replace>
  Dashboard
</Link>

The case where this actually matters: multi-step flows where you don't want the back button to walk the user through intermediate states. A classic example is a login page — once someone authenticates, you generally don't want "back" from the dashboard to land them on the login form again. Redirecting with replace (or the equivalent router.replace() call) prevents that.

scroll — controlling where the viewport lands

This is one of the props people get bitten by without realizing it's configurable. Next.js's default scroll behavior tries to preserve your scroll position across a navigation, the same way a browser's native back/forward buttons do — but only if the page you're navigating to is already visible in the viewport. If it isn't, Next.js scrolls to the top of the new page.

The mechanism behind that "scroll to top" fallback is worth understanding, because it explains behavior that otherwise looks like a bug: Next.js walks the DOM looking for a scrollable target, but it deliberately skips elements that are position: sticky or position: fixed, and skips anything that isn't actually rendering visible content (a display: none wrapper, for instance). It keeps checking sibling elements until it finds something scrollable and visible, then scrolls that into view. If your layout is unusual — say, a sticky sidebar wrapping the main content — this walk can occasionally pick a target you didn't expect.

To opt out entirely:

<Link href="/dashboard" scroll={false}>
  Dashboard
</Link>

The same option exists on the imperative API, if you're navigating from code instead of a link:

"use client";
import { useRouter } from "next/navigation";

export function GoToDashboard() {
  const router = useRouter();
  return (
    <button onClick={() => router.push("/dashboard", { scroll: false })}>
      Dashboard
    </button>
  );
}

The sticky-header problem. Because Next.js skips sticky/fixed elements when picking a scroll target, hash-link navigation (/dashboard#settings) can leave content tucked underneath a sticky header — the browser scrolls the target element into view, but doesn't know your header is covering the top 64px of the viewport. This isn't really a Next.js bug; it's a general CSS scrolling problem, and the fix is a CSS one too:

html {
  scroll-padding-top: 64px; /* match your sticky header's height */
}

If you'd rather apply the offset per-element instead of globally, scroll-margin-top on the specific target does the same job without affecting every scroll-into-view on the page.

prefetch — the prop with the most nuance

Prefetching is what makes Next.js navigations feel instant: when a <Link> scrolls into the viewport, Next.js quietly fetches the linked route (and, depending on the route type, its data) in the background, so that by the time you actually click, the work is already done. It's important to know this only happens in production builds — if you're testing perceived navigation speed, next dev will not show you the real picture.

The prefetch prop takes three meaningfully different values:

  • "auto" or omitted (the default): behavior depends on whether the route is static or dynamic. Static routes get fully prefetched, data included. Dynamic routes only get prefetched down to the nearest loading.js boundary — Next.js won't eagerly fetch data it doesn't know is safe to compute ahead of time.
  • true: forces a full prefetch regardless of whether the route is static or dynamic. If you've adopted Partial Prefetching (partialPrefetching: true in next.config.js), this also pulls in the route's App Shell plus any cached content tied to the link's specific URL — see the dedicated article on optimizing prefetching for what that unlocks.
  • false: prefetching never happens, neither on viewport entry nor on hover.

That default's meaning actually shifts once Partial Prefetching is enabled: "auto" then means "prefetch the App Shell" rather than "prefetch the full page," since the whole point of that feature is separating the static shell from the parts of a page that depend on request-specific data.

Where this bites people: a page rendering hundreds of <Link> components — a long product listing, a data table with row-level links — can trigger hundreds of simultaneous prefetch requests the moment it renders, if every link is visible or nearly visible at once. That's rarely what you want. Setting prefetch={false} on bulk/list links and relying on hover-triggered fetches (or just accepting the slightly slower first navigation) is a reasonable trade-off in that situation, and one the docs don't spell out explicitly — you have to infer it from how prefetching is described.

<Link href="/dashboard" prefetch={false}>
  Dashboard
</Link>

onNavigate — hooking into client-side transitions specifically

onNavigate looks like onClick at first glance, but the distinction matters. onClick fires for any click on the link — including modifier-key clicks that open a new tab, and clicks on links that point to external URLs. onNavigate fires only when Next.js is actually about to perform a same-origin, client-side transition, which makes it the right hook for anything that should only apply to in-app navigation.

"use client";
import Link from "next/link";

export function DashboardLink() {
  return (
    <Link
      href="/dashboard"
      onNavigate={(event) => {
        console.log("client-side navigation starting");
        // event.preventDefault() cancels the navigation
      }}
    >
      Dashboard
    </Link>
  );
}

A few concrete differences worth keeping straight:

  • Ctrl/Cmd-click to open in a new tab fires onClick but not onNavigate, since Next.js correctly lets the browser's native "open in new tab" behavior take over instead of intercepting it.
  • A <Link> pointing at an external domain never triggers onNavigate — again, because there's no client-side transition happening; the browser just navigates normally.
  • A download attribute changes what the browser does with the click entirely (it treats it as a file download), which also bypasses onNavigate.

The practical use case is guarding navigation — for example, warning a user about unsaved form changes before letting them leave a page. Because onNavigate gives you a real preventDefault(), you can build that entirely at the component level without reaching for beforeunload hacks (which only cover full page unloads, not client-side route changes anyway).

transitionTypes — hooking into View Transitions

If you're using React's <ViewTransition> component to animate between routes, transitionTypes lets you tag a specific navigation with one or more transition type strings, which get forwarded to React.addTransitionType() during the navigation. Your <ViewTransition> components can then branch their animation based on which type fired — a "slide in from the right" for forward navigation versus a "slide out" for back navigation, for instance.

<Link href="/about" transitionTypes={["slide-in"]}>
  About
</Link>

This is a fairly recent addition (Next.js 16.2), and it only does anything meaningful if you've already got <ViewTransition> set up elsewhere in your tree — on its own, passing this prop with no corresponding transition-aware component is a no-op.

Patterns you'll actually reach for

Highlighting the active link. There's no isActive prop; you build this yourself by comparing the current path against each link's href, using usePathname():

"use client";
import { usePathname } from "next/navigation";
import Link from "next/link";

const NAV_ITEMS = [
  { href: "/", label: "Home" },
  { href: "/about", label: "About" },
];

export function PrimaryNav() {
  const pathname = usePathname();

  return (
    <nav>
      {NAV_ITEMS.map((item) => (
        <Link
          key={item.href}
          href={item.href}
          className={pathname === item.href ? "nav-link active" : "nav-link"}
        >
          {item.label}
        </Link>
      ))}
    </nav>
  );
}

Watch out for exact-match comparisons on nested routes — pathname === "/blog" won't match /blog/my-post, so for section-level highlighting you generally want pathname.startsWith("/blog") instead, with a special case for the root / (which startsWith would otherwise match against everything).

Linking to dynamic segments in a list. Template literals are all you need; there's no special dynamic-route API on the <Link> side:

import Link from "next/link";

interface 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>
  );
}

Prefetching correctly behind a Proxy rewrite. This one is genuinely easy to get wrong. If your proxy.ts rewrites /dashboard to either /auth/dashboard or /public/dashboard based on a cookie, and your <Link> just says href="/dashboard", Next.js has no way to know which underlying route to prefetch without asking the Proxy — and by design, it avoids firing an extra request just to figure that out.

The fix is to tell <Link> both things directly: the URL to display in the address bar (via the as prop) and the URL to actually prefetch and navigate to (via href):

"use client";
import Link from "next/link";
import { useIsAuthed } from "./hooks/use-is-authed";

export function DashboardLink() {
  const isAuthed = useIsAuthed();
  const target = isAuthed ? "/auth/dashboard" : "/public/dashboard";

  return (
    <Link as="/dashboard" href={target}>
      Dashboard
    </Link>
  );
}

Without this, prefetching for rewritten routes behind a Proxy either silently fails or forces an extra round-trip through the Proxy just to resolve the target — neither of which you want on a hot path like a dashboard link.

Blocking navigation for unsaved changes. onNavigate combined with a shared context is the cleanest way to guard navigation app-wide, rather than wiring a confirm dialog into every individual link:

"use client";
import { createContext, useContext, useState } from "react";

const BlockerContext = createContext<{
  isBlocked: boolean;
  setIsBlocked: (v: boolean) => void;
}>({ isBlocked: false, setIsBlocked: () => {} });

export function NavigationBlockerProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [isBlocked, setIsBlocked] = useState(false);
  return (
    <BlockerContext.Provider value={{ isBlocked, setIsBlocked }}>
      {children}
    </BlockerContext.Provider>
  );
}

export const useNavigationBlocker = () => useContext(BlockerContext);

Then wrap <Link> once in a shared component that checks the shared flag before allowing the transition:

"use client";
import Link from "next/link";
import { useNavigationBlocker } from "./navigation-blocker";

export function GuardedLink(props: React.ComponentProps<typeof Link>) {
  const { isBlocked } = useNavigationBlocker();

  return (
    <Link
      {...props}
      onNavigate={(event) => {
        if (isBlocked && !window.confirm("Discard unsaved changes?")) {
          event.preventDefault();
        }
      }}
    />
  );
}

Every link in your navigation switches to GuardedLink, and any form that sets isBlocked to true on change (and clears it on submit) now automatically protects every navigation path in the app — sidebar links, breadcrumbs, footer links, all of it — from a single point of control.

What changed over time (and why it matters if you're reading old code)

If you're working in a codebase that predates Next.js 13, or copying a snippet from an old Stack Overflow answer, you'll see <Link href="/about"><a>About</a></Link> — a child <a> tag nested inside <Link>. That requirement was removed in Next.js 13; <Link> renders its own anchor now, and wrapping a manual <a> inside it is not just unnecessary but will produce a nested-anchor DOM error. There's an official codemod for stripping that pattern out of an older codebase automatically, so you don't have to hand-edit every link.

Two other version-specific details worth knowing: onNavigate was added in 15.3, and "auto" as an explicit alias for the default prefetch behavior arrived in 15.4 (before that you'd just omit the prop entirely to get the same effect — passing "auto" on an older version does nothing useful). And as of 16.2, transitionTypes is the newest prop on the list, tied to React's View Transition support.

Key Takeaways

PropDefaultWhat it controls
href— (required)Destination path; accepts a string or { pathname, query } object
replacefalseSwap the current history entry instead of pushing a new one
scrolltrueWhether Next.js scrolls to the top of a newly navigated page when it isn't already in view
prefetch"auto" (or App Shell only, if Partial Prefetching is on)How aggressively the linked route is fetched ahead of a click
onNavigateFires only for same-origin, client-side transitions — the right hook for navigation guards
transitionTypesTags a navigation for React's <ViewTransition> to branch its animation on

<Link> is deceptively simple on the surface, but almost every prop exists because someone hit a real problem with the defaults: scroll position jumping unexpectedly, prefetch storms on long lists, rewrites via Proxy breaking prefetch resolution, or navigation happening mid-form-edit. Knowing this prop surface well means you reach for the right one-line fix instead of reinventing it with manual router.push() calls and useEffect hooks.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper