(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.
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
- 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.
- Show a loading indicator on the page.
- 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
). - 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());
We will now create a fetch event listener to do the following:
- Find out which browser tab fired each event by reading the
clientId
. - Get the full URL of the request from
request.url
. - Get the intended
destination
of this request to make sure it was a page request and not an image or audio request. - Call
waitUntil
so that the service worker will wait until we are done before exiting itself. - 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,
}),
),
);
});
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";
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);
}
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:
- Store a
loading
state inside auseState
hook. - Attach event listeners to detect
onclick
andonmessage
. - Whenever the user clicks anywhere, save the time and the current path at the moment of the click.
- 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 totrue
. - 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;
}
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>
);
}
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;
}
This will make it fade in slowly for 1 second and fade out quickly.
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>
);
//...
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.
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 }
}
Top comments (2)
For whatever reason, it didn't work for me.
Amazing article. Your approach to solving such a niche problem looks very elegant.
I could test it on Chrome (v129) without any issues. Next.js's design choice assumes every route is prefetched, but that'd be an overhead with dynamic routes or a large number of navigatable routes IMO, and they should preferably give a solution to this issue out-of-the-box.
Anyway, thanks for this solution. :)