DEV Community

Arkadiusz Pawlak
Arkadiusz Pawlak

Posted on

Stop using framework specific <Link> component. Use this instead...

Pretty much every JS framwork today has a special <Link> component which wraps HTML <a> to enrich user experience while navigating around the pages (expect SvelteKit, which we will cover later).

Where this pattern fall apart?

  • You are rendering HTML from CMS's Rich text editor, anchors in this rendered HTML is not covered with magic that applies for <Link> components. To support it you would need to parse that HTML, which wouldn't be easy.
  • You would like to create a shared UI library for React which would be used in different frameworks like Remix, Next.js, TanStack Start. To support rendering <Link> components you would need some kind of EnvironmentContext or render functions.
  • Legacy library support which renders anchor tags without any way to enrich them with client side navigations.

One framework does it right and does not give you any special <Link> component. This framework is SvelteKit and it is the inspiration for this article.

In SvelteKit you just render the plain HTML <a> tag and you are good to go! It is such a no brainer that it insipired me to look into the SvelteKit sources and check how it is done there. It didn't look too complex so I decided to port it to Next.js.

But first, how it is working?

The whole trick is based on browser events bubbling up the tree. You just set the listener on the document element and listen for click events and then you check whether it is a an anchor tag, it is not external and other checks. There are actually lot of things to check, but you will see it in the code example.

It is filled with comments with explanations... They start with // EXPL

"use client";

import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useLayoutEffect, useState } from "react";

export const origin = typeof window !== "undefined" ? location.origin : "";
// EXPL: if your app lives on a subpath you have to add it here...
export const base = ""; // or for example "/basepath"

export const PRELOAD_PRIORITIES = {
  tap: 1,
  hover: 2,
  viewport: 3,
  eager: 4,
  off: -1,
  false: -1,
} as const;

const levels = {
  ...PRELOAD_PRIORITIES,
  "": PRELOAD_PRIORITIES.hover,
};

export function stripHash({ href }: { href: string }) {
  return href.split("#")[0];
}

// EXPL: just tests if the URL is external one so we should do pure classic navigation
export function isExternalUrl(url: URL, base: string) {
  if (url.origin !== origin || !url.pathname.startsWith(base)) {
    return true;
  }

  return false;
}

// EXPL: gets all necessary info about anchors attributes and computes some important booleans and values...
export function getLinkInfo(a: HTMLAnchorElement | SVGAElement, base: string) {
  let url: URL | undefined;

  try {
    url = new URL(
      a instanceof SVGAElement ? a.href.baseVal : a.href,
      document.baseURI
    );
  } catch {}

  const target = a instanceof SVGAElement ? a.target.baseVal : a.target;

  const external =
    !url ||
    !!target ||
    isExternalUrl(url, base) ||
    (a.getAttribute("rel") || "").split(/\s+/).includes("external");

  const download = url?.origin === origin && a.hasAttribute("download");

  return { url, external, target, download };
}

function parentElement(element: Element) {
  let parent = element.assignedSlot ?? element.parentNode;

  // @ts-expect-error handle shadow roots
  if (parent?.nodeType === 11) parent = parent.host;

  return parent as Element;
}

// EXPL: if you would click on img tag inside an <a> tag the event target would be that img so you have to traverse the tree to find actual <a> tag...
export function findAnchor(element: Element, target: Element) {
  while (element && element !== target) {
    if (
      element.nodeName.toUpperCase() === "A" &&
      element.hasAttribute("href")
    ) {
      return element as HTMLAnchorElement | SVGAElement;
    }

    element = parentElement(element) as Element;
  }
}
// EXPL: just for typesafety
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const validLinkOptions = {
  preload: ["", "off", "false", "tap", "hover", "eager", "viewport"],
  noscroll: ["", "true", "off", "false"],
  reload: ["", "true", "off", "false"],
  replace: ["", "true", "off", "false"],
} as const;

type ValidLinkOptions = typeof validLinkOptions;
type ValidLinkOptionKeys = keyof ValidLinkOptions;

// EXPL: Like SvelteKit it support configuration via the data attributes, here is how the data attribute should be named.
function linkOption<T extends ValidLinkOptionKeys>(element: Element, name: T) {
  const value = element.getAttribute(`data-linky-${name}`) as
    | ValidLinkOptions[T][number]
    | null;

  return value;
}

// EXPL: here you extract all the parameters that could be present on the a tag. SvelteKit has more there but Next.js router has less options so I removed few of there. Also pay attention to that while loop, this pattern support setting those router parameters in a parent elements and those are inherited. That's why the default behaviors are configured on the <body> element
export function getRouterOptions(element: HTMLAnchorElement | SVGAElement) {
  let noscroll: ValidLinkOptions["noscroll"][number] | null = null;

  let preload: ValidLinkOptions["preload"][number] | null = null;

  let reload: ValidLinkOptions["reload"][number] | null = null;

  let replace: ValidLinkOptions["replace"][number] | null = null;

  let el = element as Element;

  while (el && el !== document.documentElement) {
    if (preload === null) preload = linkOption(el, "preload");
    if (noscroll === null) noscroll = linkOption(el, "noscroll");
    if (reload === null) reload = linkOption(el, "reload");
    if (replace === null) replace = linkOption(el, "replace");

    el = parentElement(el);
  }

  function getOptionState(value: string | null) {
    switch (value) {
      case "":
      case "true":
        return true;
      case "off":
      case "false":
        return false;
      default:
        return undefined;
    }
  }

  return {
    preload: levels[preload ?? "off"],
    noscroll: getOptionState(noscroll),
    reload: getOptionState(reload),
    replace: getOptionState(replace),
  };
}

// EXPL: You have to handle scrolling to elements via ids
function scrollToHash(a: HTMLAnchorElement | SVGAElement, hash: string) {
  if (
    hash === "" ||
    (hash === "top" && a.ownerDocument.getElementById("top") === null)
  ) {
    window.scrollTo({ top: 0 });
  } else {
    const element = a.ownerDocument.getElementById(decodeURIComponent(hash));
    if (element) {
      element.scrollIntoView();
      element.focus();
    }
  }
}

// EXPL: container on which you will listen for events. Function there is needed to prevent errors
const container = () => document?.documentElement;

// EXPL: this is the main handler for event.
const handleClick = async (
  event: MouseEvent,
  router: {
    push: (url: string, options: { scroll: boolean }) => void;
    replace: (url: string, options: { scroll: boolean }) => void;
  }
) => {
  // Adapted from https://github.com/visionmedia/page.js
  // MIT license https://github.com/visionmedia/page.js#license
  if (event.button || event.which !== 1) return;
  if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
  if (event.defaultPrevented) return;

  const a = findAnchor(event.composedPath()[0] as Element, container());
  if (!a) return;

  // EXPL: Next.js components are marked with this data attribute so we     don't interfere with it
  if (a.hasAttribute("data-next-link")) {
    return;
  }

  const { url, external, target, download } = getLinkInfo(a, base);
  if (!url) return;

  if (target === "_parent" || target === "_top") {
    if (window.parent !== window) return;
  } else if (target && target !== "_self") {
    return;
  }

  const options = getRouterOptions(a);
  const isSvgAElement = a instanceof SVGAElement;

  // Ignore URL protocols that differ to the current one and are not http(s) (e.g. `mailto:`, `tel:`, `myapp:`, etc.)
  // This may be wrong when the protocol is x: and the link goes to y:.. which should be treated as an external
  // navigation, but it's not clear how to handle that case and it's not likely to come up in practice.
  // MEMO: Without this condition, firefox will open mailer twice.
  // See:
  // - https://github.com/sveltejs/kit/issues/4045
  // - https://github.com/sveltejs/kit/issues/5725
  // - https://github.com/sveltejs/kit/issues/6496
  if (
    !isSvgAElement &&
    url.protocol !== location.protocol &&
    !(url.protocol === "https:" || url.protocol === "http:")
  )
    return;

  if (download) return;

  const [nonhash, hash] = url.href.split("#");
  const samePathname = nonhash === stripHash(location);

  if (external || (options.reload && (!samePathname || !hash))) {
    return;
  }

  // Check if new url only differs by hash and use the browser default behavior in that case
  // This will ensure the `hashchange` event is fired
  // Removing the hash does a full page navigation in the browser, so make sure a hash is present
  if (hash !== undefined && samePathname) {
    // If we are trying to navigate to the same hash, we should only
    // attempt to scroll to that element and avoid any history changes.
    // Otherwise, this can cause Firefox to incorrectly assign a null
    // history state value without any signal that we can detect.
    const [, current_hash] = location.href.split("#");
    if (current_hash === hash) {
      event.preventDefault();

      // We're already on /# and click on a link that goes to /#, or we're on
      // /#top and click on a link that goes to /#top. In those cases just go to
      // the top of the page, and avoid a history change.
      scrollToHash(a, hash);

      return;
    }

    event.preventDefault();
    window.history[options.replace ? "replaceState" : "pushState"](
      null,
      "",
      url.href
    );
    scrollToHash(a, hash);

    return;
  }

  event.preventDefault();

  // allow the browser to repaint before navigating —
  // this prevents INP scores being penalised
  await new Promise((fulfil) => {
    requestAnimationFrame(() => {
      setTimeout(fulfil, 0);
    });

    setTimeout(fulfil, 100); // fallback for edge case where rAF doesn't fire because e.g. tab was backgrounded
  });

  router[options.replace ? "replace" : "push"](url.href, {
    scroll: !options.noscroll,
  });
};

// EXPL: this pattern also support the preloading of pages, same as SvelteKit
function setupPreload(preloadFn: (url: string) => void) {
  let mouseMoveTimeout: NodeJS.Timeout | null = null;
  let currentA: Element | null = null;

  container().addEventListener("mousemove", (event) => {
    const target = event.target as Element;
    if (target.hasAttribute("data-next-link")) {
      return;
    }

    if (mouseMoveTimeout) {
      clearTimeout(mouseMoveTimeout);
    }
    mouseMoveTimeout = setTimeout(() => {
      preload(target, 2);
    }, 20);
  });

  function tap(event: Event) {
    if (event.defaultPrevented) return;
    if ((event.target as Element).hasAttribute("data-next-link")) {
      return;
    }
    preload(event.composedPath()[0] as Element, 1);
  }

  container().addEventListener("mousedown", tap);
  container().addEventListener("touchstart", tap, { passive: true });

  const observer = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting) {
          preloadFn((entry.target as HTMLAnchorElement).href);
          observer.unobserve(entry.target);
        }
      }
    },
    { threshold: 0 }
  );

  function getFullPath(url: URL | Location) {
    return url.pathname + (url.search || "");
  }

  async function preload(element: Element, priority: number) {
    const a = findAnchor(element, container());
    if (!a || a === currentA) return;

    currentA = a;

    const { url, external, download } = getLinkInfo(a, base);
    if (external || download) return;

    const options = getRouterOptions(a);

    // we don't want to preload data for a page we're already on
    const sameUrl = url && getFullPath(location) === getFullPath(url);

    if (!options.reload && !sameUrl && url) {
      if (priority <= options.preload) {
        preloadFn(getFullPath(url));
      }
    }
  }

  // EXPL: this is invoked on every page change
  function afterNavigate() {
    observer.disconnect();

    for (const a of container().querySelectorAll("a")) {
      const { url, external, download } = getLinkInfo(a, base);
      if (a.hasAttribute("data-next-link")) {
        return;
      }
      if (external || download) continue;

      const options = getRouterOptions(a);
      if (options.reload) continue;

      if (options.preload === PRELOAD_PRIORITIES.viewport) {
        observer.observe(a);
      }

      if (url && options.preload === PRELOAD_PRIORITIES.eager) {
        preloadFn(getFullPath(url));
      }
    }
  }

  return { afterNavigate };
}

const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? useLayoutEffect : useEffect;

// EXPL: just a component which return null. Remember to wrap it with <Suspense> as useSearchParams hook will opt out you from the static rendering.
export const LinkListener = () => {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const [preloadSetup] = useState(() => {
    if (typeof window === "undefined") {
      return null;
    }

    return setupPreload((...args) => {
      router.prefetch(...args);
    });
  });

  useEffect(() => {
    const handle = (event: MouseEvent) => handleClick(event, router);

    document.body.addEventListener("click", handle);

    return () => {
      document.body.removeEventListener("click", handle);
    };
  }, [router]);

  const fullUrl =
    pathname + (searchParams.size > 0 ? "?" : "") + searchParams.toString();

  // EXPL: run it on every page change
  useIsomorphicLayoutEffect(() => {
    preloadSetup?.afterNavigate();
  }, [fullUrl, preloadSetup]);

  return null;
};
Enter fullscreen mode Exit fullscreen mode

That's the whole clue about this pattern. For it to work with Next.js you would need also data-linky-preload="hover" on body element

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
        data-linky-preload="hover"
      >
        {children}
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Also you need a wrapper component around Next.js <Link> component to apply that special data attribute and also to globally configure prefetching so you won't kill your server right away when few users enters your page

import React, { ComponentPropsWithRef } from "react";
import NextLink from "next/link";

export const Link = (props: ComponentPropsWithRef<typeof NextLink>) => {
  return <NextLink prefetch={false} {...props} data-next-link />;
};
Enter fullscreen mode Exit fullscreen mode

Here you have the link to the playground so you can play with it: StackBlitz

This whole article was inspired by SvelteKit and how it is implemented here. The code from source, was adjusted so it would work with Next.js and possibly other frameworks in the future!

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs