Type something to search...
uNext.js seLinkStatus

uNext.js seLinkStatus

Click a link in a well-optimized Next.js app and, most of the time, nothing visible happens between the click and the new page appearing. That's the point — prefetching means the destination is usually already sitting in the client cache by the time you click, so the "navigation" is really just a swap of already-fetched content. But "usually" isn't "always," and when a route isn't prefetched, or prefetching is deliberately turned off, there's a real gap between the click and the response where a user can reasonably wonder if anything happened at all. useLinkStatus exists to fill exactly that gap — not with a full-page spinner, but with a small, deliberate signal that says "this is working."

It's a narrow tool for a narrow problem, and knowing when not to reach for it is as important as knowing how to use it.

The problem it solves

Next.js's <Link> component is fast because of prefetching: when a link scrolls into view (or on hover, depending on configuration), Next.js quietly fetches the destination route in the background. By the time you click, the work is often already done, and the navigation completes almost instantly with no loading state needed at all.

But prefetching isn't guaranteed to finish — or to happen at all — in every case:

  • You've explicitly disabled it with prefetch={false} on a Link, usually because the destination is expensive to prefetch for every visible link (a large dashboard, a list of hundreds of items) and you'd rather pay that cost only for links people actually click.
  • The user clicks before prefetching has had time to complete — a fast click on a slow connection, or a link that just scrolled into view.
  • The destination route is dynamic and doesn't have a loading.js file, so there's no route-level fallback UI to bridge the gap.

In any of these cases, clicking the link triggers a real network round-trip, and until it resolves, the UI can look inert. useLinkStatus reports whether that resolution is still in flight, so you can render something — a spinner, a subtle animation, a change in cursor — while it happens.

Before you reach for it: check the alternatives first

The Next.js docs make a point of steering you away from useLinkStatus as a default, and it's worth taking seriously. Two other mechanisms usually solve the same underlying problem more completely:

Prefetching. If a route can be prefetched, the pending state this hook reports will simply be skipped most of the time in production — the data's already there. If you've disabled prefetching for performance reasons, it's worth double-checking whether that's still the right tradeoff for the specific link in question, rather than only patching over the resulting flash.

loading.js. If the destination route has a loading.js file, Next.js renders that file's fallback UI immediately when the route starts loading — a route-level, full-context loading state that doesn't require any component-level plumbing. For most "this page is taking a moment to load" situations, loading.js is the correct, larger-grained tool.

useLinkStatus is for the residual case: a specific link, in a specific place, where neither of the above fully covers the gap — usually because prefetching is off by design and the destination has no meaningful loading skeleton of its own. Treat it as a targeted patch for a link you've identified as slow, not a default you sprinkle on every <Link> in the app.

Basic usage

useLinkStatus is a hook exported from next/link, and it takes no arguments:

const { pending } = useLinkStatus();

It returns a single boolean property:

PropertyTypeDescription
pendingbooleantrue from click until the browser history updates, false otherwise

The one non-obvious rule that governs everything else: useLinkStatus must be called from a descendant component of the Link it's tracking, not from the same component that renders the Link itself. Next.js uses React context to communicate the pending state down through <Link>'s children, so the hook only works when it's genuinely nested inside one.

// app/hint.tsx
"use client";

import Link from "next/link";
import { useLinkStatus } from "next/link";

function Hint() {
  const { pending } = useLinkStatus();
  return (
    <span aria-hidden className={`link-hint ${pending ? "is-pending" : ""}`} />
  );
}

export default function Header() {
  return (
    <header>
      <Link href="/dashboard" prefetch={false}>
        <span className="label">Dashboard</span> <Hint />
      </Link>
    </header>
  );
}

Notice the structure here: Header renders the Link, and Hint — a separate component nested inside that Link — is the one that calls useLinkStatus. If you tried to call the hook directly inside Header, next to where the Link itself is rendered, it wouldn't have the right context to read from and the pending state wouldn't work as expected.

A complete example: an inline pending indicator across a menu

Here's a more realistic version, with the indicator extracted into its own file and reused across a set of navigation links — the kind of pattern you'd actually use in a sidebar or category menu:

// app/components/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" : ""}`} />
  );
}
// app/shop/layout.tsx
import Link from "next/link";
import LoadingIndicator from "./components/loading-indicator";

const links = [
  { href: "/shop/electronics", label: "Electronics" },
  { href: "/shop/clothing", label: "Clothing" },
  { href: "/shop/books", label: "Books" },
];

function Menubar() {
  return (
    <div>
      {links.map((link) => (
        <Link key={link.label} href={link.href}>
          <span className="label">{link.label}</span> <LoadingIndicator />
        </Link>
      ))}
    </div>
  );
}

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div>
      <Menubar />
      {children}
    </div>
  );
}

Each Link gets its own independent LoadingIndicator instance, each reading its own pending state from the Link it's nested inside. Click "Clothing" and only that link's hint activates — which is exactly the multi-link behavior worth understanding next.

What happens when you click several links quickly

If a user clicks one link, then clicks another before the first navigation resolves, only the most recently clicked link's pending state shows as true. This is a deliberate design choice, not a bug to route around: browser navigation is inherently single-threaded from the user's perspective — you end up on one page, not several — so showing pending state on more than one link at once would be actively misleading about what's about to happen. If your UI needs to communicate "you clicked something, hold on," anchoring that to the last click is the correct behavior, not an edge case to patch.

Avoiding layout shift

The docs flag this directly, and it's the single most common way this hook gets misused: rendering an indicator that appears out of nowhere, pushing surrounding text sideways, tends to look worse than having no indicator at all. Cumulative Layout Shift (CLS) is one of the Core Web Vitals, and an inline hint that pops in and out of the DOM is a textbook way to hurt it.

The fix is to always render the hint element, and toggle its visibility rather than its presence:

/* app/styles/global.css */
.link-hint {
  display: inline-block;
  width: 0.6em;
  height: 0.6em;
  margin-left: 0.25rem;
  border-radius: 9999px;
  background: currentColor;
  opacity: 0;
  visibility: hidden; /* reserve space without showing the hint */
}

.link-hint.is-pending {
  visibility: visible;
  animation-name: fadeIn, pulse;
  animation-duration: 200ms, 1s;
  animation-delay: 100ms, 100ms;
  animation-timing-function: ease, ease-in-out;
  animation-iteration-count: 1, infinite;
  animation-fill-mode: forwards, none;
}

@keyframes fadeIn {
  to {
    opacity: 0.35;
  }
}
@keyframes pulse {
  50% {
    opacity: 0.15;
  }
}

The element with class link-hint is always in the DOM, always occupying its fixed 0.6em circle of space — it's just invisible (visibility: hidden, opacity: 0) until is-pending is added. Because the space is reserved from the start, nothing around it needs to reflow when the indicator appears.

Delaying the indicator so fast navigations don't flash

There's a second, related UX problem: if most of your navigations resolve in under 100ms, showing the indicator at all for those fast ones just adds visual noise — a flash of a dot that appears and vanishes almost as fast as the eye can register it. The animation above handles this with animation-delay: 100ms: the hint stays invisible for the first 100ms of the pending state, and only fades in if the navigation is still unresolved after that. Fast navigations never trigger a visible flash; slow ones get a hint after a reasonable grace period.

This two-stage design — reserve space unconditionally, then delay visibility — is worth lifting as a template for any "is this thing slow?" indicator, not just this one.

Common mistakes

Calling the hook in the same component that renders the Link. As covered above, this silently breaks the context lookup. If pending never seems to update, this is almost always why — double-check that the hook is called from a genuine child component, not a sibling or the same function.

Using it as a substitute for loading.js. If a route has meaningful content-specific loading needs (a skeleton matching the destination's layout, a progress indicator for a known-slow operation), loading.js is the right tool. useLinkStatus communicates "something is happening" at the link level; it doesn't replace a route's own loading UI.

Adding it to every link "just in case." Every additional pending-state component is additional client-side JavaScript and additional complexity for a signal that, on a well-prefetched app, will almost never fire. Reach for it after you've identified a specific link that's actually slow, not preemptively across a whole navigation menu.

Forgetting this is Client Component territory. useLinkStatus is a React hook, so any component that calls it needs 'use client' at the top of its file — including the small indicator component itself, even if the page around it is otherwise a Server Component.

Expecting it to work in the Pages Router. It's an App Router-only API. In the Pages Router, useLinkStatus is available for import compatibility but always returns { pending: false } — it won't throw, but it also won't do anything.

Key Takeaways

QuestionAnswer
What does it return?A single { pending: boolean } object, no parameters
Where must it be called?From a component nested inside the Link it tracks, never the same component that renders the Link
When is it most useful?When prefetch={false} is set, or the destination lacks a loading.js fallback
What happens on rapid multi-click?Only the most recently clicked link shows pending: true
Biggest pitfall?Rendering the indicator conditionally in a way that causes layout shift — always render it, toggle visibility instead
Pages Router support?None — always returns pending: false there
Introduced inv15.3.0

useLinkStatus is a small hook doing one specific job well: telling you, precisely and only, whether a particular link's navigation is still in flight. It's not a loading-state framework and it's not trying to be — it's the last 10% of polish for the specific links where prefetching and loading.js don't already cover you, and it's worth reaching for exactly that often, and no more.

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