DEV Community

Cover image for Next.js App Router navigation indicator and the delay after onclick
Terry Yuen
Terry Yuen

Posted on • Updated on

Next.js App Router navigation indicator and the delay after onclick

(Jump to the workaround.)

Next.js App Router allows you to define a page navigation indicator by exporting a component from the loading.js file located in your app folder.

When the user navigates to https://{domain.tld}/hello/, Next.js will dynamically load /src/app/hello/page.js and while it is downloading, it will show /src/app/loading.js.

If you don't define this loading.js file and your page loads slowly, your users will think the app isn't doing anything.

But did you know there is still an extra step before loading.js gets shown?

Why is there a delay from onclick to showing loading.js?

If you have a slow Internet connection, you may have noticed that after clicking on a link, the loading indicator (defined inside loading.js) doesn't show immediately.

This is because when you click on a link, Next.js first needs to download a small data file and only after it has downloaded will it show the loading.js component.

There is no limit on how long it waits.

If the small data file takes 5 seconds to finish downloading, the page will remain unchanged for 5 seconds.

This leads to a bad user experience.

A mouse clicking on a link with no feedback

Minimize delay with link preloading

Next.js minimizes the impact of downloading the small data file by preloading all links that are visible on screen so that when you do click on the link, the page is already in your cache and the page loads instantly.

This is not foolproof if your page has a long list of links and the user clicks on the last link before it had a chance to preload. (FYI, Chrome has a max. of 6 concurrent connections per hostname.)

Or your developer has turned off link preloading because your website has too many ever-changing links and the server was inundated with too many fresh requests at once.

No way to show the loading indicator first

Not showing the loading indicator when the small data file is being downloaded was a design choice by Next.js and affects both Client-Side Rendering (CSR/SPA) and Server-Side Rendering (SSR).

While it is an edge case, creating a solution with minimal effort and overhead is worthwhile.

Any user that does not see a visual feedback within 1 to 3 seconds might think the website has stopped working.

No official way to detect page navigation in Next.js

Next.js App Router (at least up to version 14) does not dispatch events when a page navigation is requested because the App Router now fully relies on React's Suspense architecture to convey loading state.

Most workarounds suggest to create your own custom Link component and create a wrapper around useRouter().push() that triggers a custom loading indicator. The problem is it would require rewriting your existing code.

An ideal approach should not require changing your app.

One way to detect page navigation would be to intercept the browser's fetch calls to see when Next.js requests match a known pattern.

The Plan

  1. Detect when a network request is made as a result of a click. We'll assume that any network request sent within 1 second after a click is related to the click.
  2. Show a loading indicator on the page.
  3. Wait until the page has navigated to a new URL. At this point the Suspense loading component (from loading.js) will show as it continues to download the actual page (page.js).
  4. Hide our loading indicator because the Suspense loading component has taken over.

Use a Service Worker to detect network calls

Create a sw.js file at the root of your public folder (/public/sw.js) to hold the service worker. If you already have have one, you can edit it instead.

Service workers don't run immediately on startup by design. We can make it run immediately by calling skipWaiting inside the install handler.

self.addEventListener("install", () => self.skipWaiting());
Enter fullscreen mode Exit fullscreen mode

We will now create a fetch event listener to do the following:

  1. Find out which browser tab fired each event by reading the clientId.
  2. Get the full URL of the request from request.url.
  3. Get the intended destination of this request to make sure it was a page request and not an image or audio request.
  4. Call waitUntil so that the service worker will wait until we are done before exiting itself.
  5. Send this info to the page using postMessage since we will be handling this event on the page itself.

It will look like this:

//public/sw.js
let ignore = { image: 1, audio: 1, video: 1, style: 1, font: 1 };

self.addEventListener("fetch", e => {
  let { request, clientId } = e;
  let { destination } = request;
  if (!clientId || ignore[destination]) return;
  e.waitUntil(
    self.clients.get(clientId).then(client =>
      client?.postMessage({
        fetchUrl: request.url,
        dest: destination,
      }),
    ),
  );
});
Enter fullscreen mode Exit fullscreen mode

Make your app load sw.js at startup

Locate your layout.js file at the root of your app folder (/src/app/layout.js).

At the top of the layout.js file add this line:

import "./injectAtRoot.js";
Enter fullscreen mode Exit fullscreen mode

Then create a file called injectAtRoot.js inside the same folder and add this contents:

//injectAtRoot.js
"use client";
if (
  typeof navigator !== "undefined" 
  && "serviceWorker" in navigator
) {
  navigator.serviceWorker.register("/sw.js")
  .catch(console.error);
}
Enter fullscreen mode Exit fullscreen mode

Create a hook to read the service worker messages and detect mouse clicks

Create a file (useOnNavigate.js) wherever you save your hooks in your Next.js App Router app (e.g. /src/app/hooks/useOnNavigate.js).

We will create a hook to do the following:

  1. Store a loading state inside a useState hook.
  2. Attach event listeners to detect onclick and onmessage.
  3. Whenever the user clicks anywhere, save the time and the current path at the moment of the click.
  4. Whenever our service worker sends a message, check if a click was made recently and if the newly fetched URL matches a Next.js small data file name format and if so, set our loading state to true.
  5. Read the current path (using usePathname) and reset the loading state whenever it changes.
//useOnNavigate.js
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";

let clickTime = 0;
let pathWhenClicked = "";

export function useOnNavigate() {
  const curPath = usePathname();

  const [loading, setLoading] = useState(false);

  useEffect(() => {
    clickTime = 0;
    if (curPath !== pathWhenClicked) {
      setLoading(false);
    }
  }, [curPath]);

  useEffect(() => {
    if (typeof navigator === "undefined") return;

    const onMessage = ({ data }) => {
      if (Date.now() - clickTime > 1000) return;

      const url = toURL(data.fetchUrl);
      if (
        url?.search.startsWith("?_rsc=") 
        && data.dest === ""
      ) {
        clickTime = 0;
        setLoading(true);
      }
    };

    const sw = navigator.serviceWorker;
    sw?.addEventListener("message", onMessage);

    const onClick = (e) => {
      clickTime = Date.now();
      pathWhenClicked = location.pathname;
    };

    addEventListener("click", onClick, true);

    return () => {
      sw?.removeEventListener("message", onMessage);
      removeEventListener("click", onClick, true);
    };
  }, []);

  return loading;
}

function toURL(url) {
  try {
    if (url) return new URL(url);
  } catch (e) {}
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Create a loading indicator that fades in slowly and fades out quickly

Now we will create a component that fades in when the page is navigating and fades out when the URL has changed.

We will create the classic animating bar at the top of the page.

First create a LoadingBar.jsx file wherever you save your components in your Next.js App Router app (e.g. /src/app/components/LoadingBar.jsx) and add the below code:

//LoadingBar.jsx
"use client";
import styles from "./LoadingBar.module.css";
import { useOnNavigate } from "./useOnNavigate";

export default function LoadingBar() {
  const loading = useOnNavigate();
  return (
    <div
      aria-busy={loading}
      className={styles.loading}></div>
  );
}
Enter fullscreen mode Exit fullscreen mode

We will use a slow fade-in effect to simulate the initial delay so that if the loading state starts and ends quickly, it would have barely faded in, making it invisible the whole time.

Create a CSS file at the same folder level as the LoadingBar component called LoadingBar.module.css in which we define our CSS as follows:

/* LoadingBar.module.css */
.loading {
  transition: opacity 0.3s ease-in;
  opacity: 0;
  will-change: opacity;
  position: fixed;
  height: 20px;
  width: 100%;
  left: 0;
  top: -10px;
  filter: blur(8px);
  background: red;
}
.loading[aria-busy="true"] {
  opacity: 1;
  transition-duration: 1s;
}
Enter fullscreen mode Exit fullscreen mode

This will make it fade in slowly for 1 second and fade out quickly.

Clicking a link shows the glowing red bar

Add the loading indicator to your app

Locate your layout.js file at the root of your app folder (/src/app/layout.js).

Inside the body tag add your <LoadingBar /> component:

//layout.js
//...
return (
  <html lang="en">
    <body>
      <LoadingBar />
      {children}
    </body>
  </html>
);
//...
Enter fullscreen mode Exit fullscreen mode

A word on testing in Chrome

You should be able to test the loading indicator in Firefox and see a red blurred bar fade in when you click a link.

Chrome doesn't support loading Service Workers if your website is not running on HTTPS with a valid certificate from an established CA. Chrome doesn't allow self-signed certificates for Service Workers.

You can test it on Firefox with for now as it accepts self-signed HTTPS certificates.

Making the loading bar animated

We will make 2 spotlights swing back and forth inside the bar. One will be black and swing from right to left and the other one is white.

Spotlight effect in the loading bar

Inside the LoadingBar.module.css file, remove the background: red and add the following at the bottom:

.loading::before,
.loading::after {
  content: "";
  display: block;
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  opacity: 0.5;
  mix-blend-mode: color;
  background: 50%/200% repeat-x;
  animation: 1s ease-in-out infinite alternate;
}
.loading::before {
  background-image: linear-gradient(
    90deg, transparent, black, transparent
  );
  animation-name: loading-rg;
}
.loading::after {
  background-image: linear-gradient(
    90deg, transparent, white, transparent
  );
  animation-name: loading-lt;
}
@keyframes loading-lt {
  from { background-position: 100% 0 }
  to { background-position: 0% 0 }
}
@keyframes loading-rg {
  from { background-position: 0% 0 }
  to { background-position: 100% 0 }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
michaeldimimu profile image
Michael Salam

For whatever reason, it didn't work for me.