Building a Next.js page load progress bar

By Sidney Liebrand on Apr 18, 20217 min read

In this post I'm going to cover why you would want to add a page load progress bar to your Next.js application as well as how you could go about implementing it without using any external libraries. Let's dive right in!

Why add a page load progress bar

If you've ever visited a slow website and clicked a navigation link, it may have felt like the page was not responding. This might have given you a certain feeling of frustration perhaps, even provoking you to click one or multiple times.

What really happened in the background was that the server got your request and started preparing this page on the back-end but it just took a while to complete. Since you didn't get any kind of indication that something was happening you just decided to mash that button again.

In the worst case the webserver actually starts processing this second click for the exact same page as well, needlessly increasing server load. This implies two negative side effects. First, the webserver processed a request twice, and second, the user has become more impatient than they already were due to poor loading experience.

Of course the button could just be disabled on click, while this does prevent the user from rage clicking it doesn't improve their browsing experience on your website at all. This is why it may help to show your users a page load progress bar.

Implementing the page load progress bar

For this part I will be using Next.js' built-in support for CSS modules and the provided useRouter hook so that we can hook into router events provided by Next.js. The choice of CSS library is completely yours, you could opt to use styled-jsx or styled-components if you'd like. Furthermore this will be a self-contained component, when it's done you can just drop <Progress /> into your Next.js page layout file and everything should just work.

Setting up the component

To start off, let's first create the files we need for this component. I'll assume you have some folder such as components/ which holds all your React components. Add a progress/ folder inside this folder and add an index.js and progress.module.css file to the progress/ folder. Your directory structure should look like this:

. (root)|- components|--|- progress|--|--|- index.js|--|--|- progress.module.css

With these files set up we can now open progress/index.js to start working on our component. We'll need to use the useRouter and useEffect hooks to bind listeners to navigation events and we'll need to use useState to keep track of the progress:

import {useEffect, useState} from 'react';import {useRouter} from 'next/router';import styles from './progress.module.css';
export default function Progress() {    const router = useRouter();    const [progress, setProgress] = useState(0);
    return (        <div className={styles.progress}>            <div                className={styles.indicator}                style={{                    width: `${progress}%`,                    opacity: progress > 0 && progress < 100 ? 1 : 0,                }}            />        </div>    )}

Of course this doesn't do much yet but at least we now have a component that we can import in our layout or header file. We did set up a little bit of dynamic styling in the .indicator element so set the width equal to ${progress}% and to set opacity to 1 whenever it is active (progress not 0 or 100). You can go ahead and import and render it on your page. Nothing will show up yet but we're going to fix that now by adding some CSS.

Styling the component

Open progress/progress.module.css and add the following:

.progress {  position: fixed;  top: 0;  left: 0;  z-index: 999999;  height: 0.15rem;  width: 100%;}
.indicator {  background-color: yellow;  position: absolute;  top: 0;  bottom: 0;  left: 0;  width: 0;  transition: all 0.1s linear, opacity 0.3s linear 0.2s;}

The .progress class is the outer container which will create a fixed space at the top of the page which .indicator will fill up. The .indicator has some transition effects to animate both width and opacity so the bar fades in and out nicely and width transitions also look smooth. If we now go ahead and set the progress to something other than 0 initially, we should see a yellow bar at the top, let's set it to 40:

const [progress, setProgress] = useState(40);

Now reload the page and you should see a progress bar already at 40% progress. This is the time you'll want to do some additional styling if you don't like how it looks. Also don't forget to set the useState default back to 0 when you're done :)

Binding the events

All we have to do now is to hook up to Next.js' router events and make this bar move on its own whenever a navigation event occurs. To do this we'll add a useEffect hook without any dependencies so that it works like componentDidMount / componentWillUnmount lifecycle methods.

We do this since we want to make sure these listeners are only bound once, and should an unmount occur we also want to make sure the old listeners are cleaned up before any new ones are attached. This allows us to set up the listeners once, and if an unmount occurs this also allows us to clean up the listeners:

useEffect(() => {    let timer;
    function start() {        setProgress(1);        increment();    }
    function increment() {        const timeout = Math.round(Math.random() * 300);
        setProgress((progress) => {            const percent = Math.round(Math.random() * 10);            const next = Math.min(progress + percent, 80);
            if (next < 80) {                timer = setTimeout(increment, timeout);                return next;            }
            return 80;        });    }
    function complete() {        clearTimeout(timer);        setProgress(100);    }
    router.events.on('routeChangeStart', start);    router.events.on('routeChangeComplete', complete);    router.events.on('routeChangeError', complete);
    return () => {        clearTimeout(timer);        router.events.off('routeChangeStart');        router.events.off('routeChangeComplete');        router.events.off('routeChangeError');    };}, []);

With all the parts set up we can now go over the start, increment and complete functions. The start function kicks off the process on routeChangeStart. It calls setProgress(1) which makes the progress bar visible after a 0.2s delay defined in the CSS opacity transition. Afterwards, it also calls increment() which will repeatedly call itself using setTimeout until a certain threshold has been reached (80 in this case). This will move the progress bar at random intervals with random percentages added.

Finally the complete function will be called either on routeChangeComplete or routeChangeError which will clear any remaining timeout set by increment and force the bar to 100 progress causing it to fill up and fade out.

We can safely leave progress at 100 here. There is no need to reset it to 0 because in our component logic we set opacity to 0 when the bar is either at 0 or 100 progress. Additionally when new navigation events occur the start function is called which always sets it to 1.

Everything together

Finally, you'll end up with this component:

import {useEffect, useState} from 'react';import {useRouter} from 'next/router';import styles from './progress.module.css';
export default function Progress() {    const router = useRouter();    const [progress, setProgress] = useState(0);
    useEffect(() => {        let timer;
        function start() {            setProgress(1);            increment();        }
        function increment() {            const timeout = Math.round(Math.random() * 300);
            setProgress((progress) => {                const percent = Math.round(Math.random() * 10);                const next = Math.min(progress + percent, 80);
                if (next < 80) {                    timer = setTimeout(increment, timeout);                    return next;                }
                return 80;            });        }
        function complete() {            clearTimeout(timer);            setProgress(100);        }
        router.events.on('routeChangeStart', start);        router.events.on('routeChangeComplete', complete);        router.events.on('routeChangeError', complete);
        return () => {            clearTimeout(timer);            router.events.off('routeChangeStart');            router.events.off('routeChangeComplete');            router.events.off('routeChangeError');        };    }, []);
    return (        <div className={styles.progress}>            <div                className={styles.indicator}                style={{                    width: `${progress}%`,                    opacity: progress > 0 && progress < 100 ? 1 : 0,                }}            />        </div>    );}

While it isn't as fancy as something like NProgress, which also shows a loading spinner, it doesn't require any library and it is also less JS and CSS. Adding an endless spinner here also wouldn't be too difficult if you really wanted to but this is something I'll leave as an exercise for the reader :)

Until next time!

👋