Mastering Next.js Prefetching: Enhance Navigation with the SuperLink Component

Mastering Next.js Prefetching: Enhance Navigation with the SuperLink Component

Build a custom component that does link prefetch on hover

Next.js has a component that enables prefetching and client-side navigation between routes: Link. It's one of the primitives upon which you build applications. Yet, there are a lot of misconceptions about how prefetching actually works. To add to the confusion, its behavior has been changing since Next.js 13 beta. How does the prefetch actually work? And can we make it more flexible?

The Link component from next/link accepts an optional prop prefetch. Back in Next.js 12 or now in Pages Router, prefetch took only two values: true or false (and it was true by default when omitted). However, these values don't really mean what you might think they do.

  • true – the route is prefetched when the link enters the viewport

  • false – the route is not prefetched when the link enters the viewport; however, the route will be prefetched on hover

That last behavior was commonly perceived as confusing because the semantics of the false value didn't obviously convey the mechanics. Also, it was impossible to disable the prefetching completely.

This changed with the introduction of App Router in Next.js 13. However, the exact mechanisms of the automatic prefetching were modified between Next 13 and Next 15, and finally, a third possibility was introduced. The workings of Link are now also different, depending on whether it points to a static or dynamic page. The prefetch prop is (still) optional and takes the following values:

  • true – the full route is prefetched when the link enters the viewport, both for static and dynamic pages

  • false – prefetching is completely disabled

  • nullnew default value – the behavior depends on whether the route is static or dynamic:

    • static – the full route will be prefetched when the link enters the viewport

    • dynamic – only a partial route down to the nearest segment with a loading.tsx boundary will be prefetched; if there's no such route, nothing is prefetched

Now, prefetching is more powerful and adjustable. However, it still lacks some flexibility. Let's fix that!

We can extend the capabilities of the Link and control prefetching by introducing a custom component that wraps around the Link.

The plan is to wrap around it, override prefetch, and programmatically prefetch on certain interactions. My idea is to do it when a user hovers over the element. Often, the time between hover and click is long enough for the prefetch to complete, and then navigating to the route is instantaneous. When we're done with that, we'll also consider other ways of interacting with the web app, and improve accessibility of our solution. Let's get to it!

Let's start by wrapping around next/link. We're using React 19, so we don't need the whole forwardRef shenanigans, and we can simply accept ref as props via ComponentPropsWithRef:

"use client";

import Link from "next/link";
import { type ComponentPropsWithRef } from "react";

export const SuperLink = (props: ComponentPropsWithRef<typeof Link>) => {
  return <Link {...props} />;
};

Now, let's override the prefetch prop and use router to programmatically prefetch instead. We'll also need to handle the fact that href can be a string or a URLObject:

"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";
import { type ComponentPropsWithRef } from "react";

export const SuperLink = (props: ComponentPropsWithRef<typeof Link>) => {
  const router = useRouter();
  const strHref = typeof props.href === "string" ? props.href : props.href.href;
  return (
    <Link
      {...props}
      prefetch={false}
      onMouseEnter={(e) => {
        if (strHref) {
          void router.prefetch(strHref);
        }
        return props.onMouseEnter?.(e);
      }}
    />
  );
};

Now, prefetching happens on mouseenter, which should be enough to make the navigation smoother than no prefetching at all. It also works for both static and dynamic routes.

Unnecessary requests

Some people on Twitter𝕏 argue that this is a poor choice because moving your cursors across the page will trigger multiple prefetches. It's true. However, with justprefetch={true}, even more requests will be made. So, I'd say our current implementation is an improvement anyway. On rare occasions, you might want to change the event or condition for which the prefetch is called. It's a tradeoff.

Accessibility

Our current solution completely ignores users navigating the application with keyboards and touch screens. We should improve it and work on accessibility, too!

👏
Shout out to ImLunaHey, RedCardinal, and JohnPhamous for reminding me of how important accessibility is.

Will adding handlers for onPointerEnter, onTouchStart and onFocus do the job? I'm no accessibility expert, but it certainly seems to be working:

"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";
import { type ComponentPropsWithRef } from "react";

export const SuperLink = (props: ComponentPropsWithRef<typeof Link>) => {
  const router = useRouter();
  const strHref = typeof props.href === "string" ? props.href : props.href.href;

  const conditionalPrefetch = () => {
    if (strHref) {
      void router.prefetch(strHref);
    }
  };

  return (
    <Link
      {...props}
      prefetch={false}
      onMouseEnter={(e) => {
        conditionalPrefetch();
        return props.onMouseEnter?.(e);
      }}
      onPointerEnter={(e) => {
        conditionalPrefetch();
        return props.onPointerEnter?.(e);
      }}
      onTouchStart={(e) => {
        conditionalPrefetch();
        return props.onTouchStart?.(e);
      }}
      onFocus={(e) => {
        conditionalPrefetch();
        return props.onFocus?.(e);
      }}
    />
  );
};

Now, again, you might argue that calling prefetch on focus will trigger many unnecessary requests. To get to the third link on the page with your keyboard, you need to also focus the first and second ones, and, in effect, you'll prefetch them, too. I agree this is not optimal and can be improved. Looking forward to your suggestions on how.

Prefetch on Fast Internet Only

Here's another idea: only trigger prefetch when the user has a stable and fast internet connection. How can we do that? Using navigator.connection. Let's try that.

💡
Using navigator.connection requires installing and configuring TypeScript types from network-information-types package.

I've omitted the rest of the code for brevity, but here's the gist of the idea:

"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";
import { type ComponentPropsWithRef } from "react";

export const SuperLink = (props: ComponentPropsWithRef<typeof Link>) => {
  const router = useRouter();
  const strHref = typeof props.href === "string" ? props.href : props.href.href;
  return (
    <Link
      {...props}
      prefetch={false}
      onMouseEnter={(e) => {
        const hasFastInternet =
          !navigator.connection || navigator.connection.effectiveType === "4g";
        if (strHref && hasFastInternet) {
          void router.prefetch(strHref);
        }
        return props.onMouseEnter?.(e);
      }}
    />
  );
};

Summary

It's not ideal, but remember the Pareto rule: We've done 80% of the job in 20% of the time. I'm open to suggestions on how this can be improved. Maybe with a debounce of some sort? Post your code in the comments!

Did you find this article valuable?

Support Michał Miszczyszyn by becoming a sponsor. Any amount is appreciated!