How to build a route progress bar in Next.js

By Hunter Becton on July 20, 2021

Next.js makes it simple to implement static generation (SSG) and server-side rendering (SSR) in React applications. SSG pages pre-render the HTML at build time and can be served instantly from a global CDN. However, SSR pages pre-render the HTML on every request, which slows the time to the first byte (TTFB). Because this happens your users will experience SSR pages as slower in the client, although your Next.js application is processing their request on the server.

A route progress bar at the top of your Next.js application will help with the feeling of slower TTFB because it signals to your users that their route change is being processed.

Watch the lesson

Project repo

The code for this project is open-source and available on GitHub. I use Gumroad for those that want to be generous and donate, but no purchase is required. 😊

Grab the code.

Next.js and Tailwind CSS starter

I chose to use Tailwind for styling my Next.js application. You can read more in the Tailwind installation guide if you're interested in how to implement Tailwind into Next.js or another React framework like Create React App or Gatsby.

I'm also using pre-built React components from Tailwind UI. They have an entire library that you can purchase, but in this lesson, I'm just using the free Tailwind UI preview components.

Zustand

In the video, I use Zustand, which is a state management library. I use Zustand because I update the progress bar state in other pages and components for downloading files, waiting on responses from the Next.js API, or any other process where I want the user to know their request is being processed. This could be achieved with React Context or another library, but I find Zustand user friendly. If you're just using the progress bar for route changes you could just use the useState React hook, which I'll cover in the _app.js section below.

If you decide to use Zustand for your project, go ahead and install it as a dependency by running npm i zustand in your project's terminal.

Next, create a folder called store and create an index.js file and useProgressStore.js file.

The index.js file will be used to export all the stores so it's easier to import them in other places, so all you need in the file, for now, is the following:

export * from './useProgressStore'

Inside the useProgressStore.js file is where you'll create the store object using Zustand:

import create from 'Zustand'

export const useProgressStore = create(set => ({
  isAnimating: false,
  setIsAnimating: isAnimating => set(() => ({ isAnimating })),
}))

Now you have a store value named isAnimating and a function named setIsAnimating that will be used to update isAnimating.

NProgress components

To help with the loading bar logic you'll be using a package called NProgress. Next.js has an official example called with-loading that uses this package, but I decided to use a React port of NProgress called react-nprogress because it makes it easier to style components with Tailwind CSS class names.

Next, install the React port of NProgress by running npm i react-nprogress in your project's terminal. Now create a folder called components and another folder called progress. Create an index.js file and export everything from the progress folder by writing the following code:

export * from './progress'

The progress folder will have four more files: index.js, Bar.js, Container.js, and Progress.js. The index.js file is going to export everything from the Progress component, so all you need to write inside is the following code:

export * from './Progress'

Bar component

In your Bar.js file write the following code:

export const Bar = ({ animationDuration, progress }) => (
  <div
    className="bg-indigo-600 fixed left-0 top-0 z-50 h-1 w-full"
    style={{
      marginLeft: `${(-1 + progress) * 100}%`,
      transition: `margin-left ${animationDuration}ms linear`,
    }}
  ></div>
)

The Bar component will take two props, which it will get from the useNProgress hook (covered later). The styling is taken care of by Tailwind CSS class names.

Container component

In your Container.js file write the following code:

export const Container = ({ animationDuration, children, isFinished }) => (
  <div
    className="pointer-events-none"
    style={{
      opacity: isFinished ? 0 : 1,
      transition: `opacity ${animationDuration}ms linear`,
    }}
  >
    {children}
  </div>
)

The Container component will be used to wrap the Bar component. It will use the isFinished prop from the useNProgress hook to control opacity and the animationDuration prop to control the opacity animation.

Progress component

With the Bar and Container component complete you can write the following in your Progress.js file:

import { useNProgress } from '@tanem/react-nprogress'

import { Bar } from './Bar'
import { Container } from './Container'

export const Progress = ({ isAnimating }) => {
  const { animationDuration, isFinished, progress } = useNProgress({
    isAnimating,
  })

  return (
    <Container animationDuration={animationDuration} isFinished={isFinished}>
      <Bar animationDuration={animationDuration} progress={progress} />
    </Container>
  )
}

The Progress component calls the useNProgress hook from react-nprogress. This hook gives you access to values from NProgress, which will be needed to pass into the Container and Bar components as props.

The only prop that's passed into the Progress component is isAnimating, which will come from useProgressStore or useState.

Add the Progress component

With the Progress component complete you can now import it and use it in your _app.js file. By using it in the _app.js file the Progress component will be rendered on every page.

As mentioned in the previous section, the Progress component only takes one prop: isAnimating. Below are examples of how to pass that prop into the Progress component depending on if you used Zustand or useState.

Zustand

Write the following code in your _app.js file if you used Zustand to manage isAnimating:

import 'tailwindcss/tailwind.css'

import { Progress } from '/components'
import { useProgressStore } from '/store'

function MyApp({ Component, pageProps }) {
  const setIsAnimating = useProgressStore(state => state.setIsAnimating)
  const isAnimating = useProgressStore(state => state.isAnimating)
  return (
    <>
      <Progress isAnimating={isAnimating} />
      <Component {...pageProps} />
    </>
  )
}

export default MyApp

useState

Write the following code in your _app.js file if you used useState to manage isAnimating:

import { useState } from 'react'
import 'tailwindcss/tailwind.css'

import { Progress } from '/components'

function MyApp({ Component, pageProps }) {
  const [isAnimating, setIsAnimating] = useState(false)
  return (
    <>
      <Progress isAnimating={isAnimating} />
      <Component {...pageProps} />
    </>
  )
}

export default MyApp

Router events

This section is focused on setting the isAnimating state based on router events, which you can get access to from a Next.js hook called useRouter. You'll also pair that with the useEffect React hook, which will help make DOM updates.

The code for implementing this will also be in your _app.js file, but will also depend on if you used Zustand or useState. Below are examples for each.

Zustand

The code in your _app.js file should be the following if you used Zustand:

import { useEffect } from 'react'
import 'tailwindcss/tailwind.css'
import { useRouter } from 'next/router'

import { Progress } from '/components'
import { useProgressStore } from '/store'

function MyApp({ Component, pageProps }) {
  const setIsAnimating = useProgressStore(state => state.setIsAnimating)
  const isAnimating = useProgressStore(state => state.isAnimating)
  const router = useRouter()
  useEffect(() => {
    const handleStart = () => {
      setIsAnimating(true)
    }
    const handleStop = () => {
      setIsAnimating(false)
    }

    router.events.on('routeChangeStart', handleStart)
    router.events.on('routeChangeComplete', handleStop)
    router.events.on('routeChangeError', handleStop)

    return () => {
      router.events.off('routeChangeStart', handleStart)
      router.events.off('routeChangeComplete', handleStop)
      router.events.off('routeChangeError', handleStop)
    }
  }, [router])
  return (
    <>
      <Progress isAnimating={isAnimating} />
      <Component {...pageProps} />
    </>
  )
}

export default MyApp

useState

The code in your _app.js file should be the following if you used useState:

import { useState, useEffect } from 'react'
import 'tailwindcss/tailwind.css'
import { useRouter } from 'next/router'

import { Progress } from '/components'

function MyApp({ Component, pageProps }) {
  const [isAnimating, setIsAnimating] = useState(false)
  const router = useRouter()
  useEffect(() => {
    const handleStart = () => {
      setIsAnimating(true)
    }
    const handleStop = () => {
      setIsAnimating(false)
    }

    router.events.on('routeChangeStart', handleStart)
    router.events.on('routeChangeComplete', handleStop)
    router.events.on('routeChangeError', handleStop)

    return () => {
      router.events.off('routeChangeStart', handleStart)
      router.events.off('routeChangeComplete', handleStop)
      router.events.off('routeChangeError', handleStop)
    }
  }, [router])
  return (
    <>
      <Progress isAnimating={isAnimating} />
      <Component {...pageProps} />
    </>
  )
}

export default MyApp

Both examples get access to the router object by calling the useRouter hook from next/router. The router events routeChangeStart, routeChangeComplete, and routeChangeError are listened to in the useEffect hook and will either call handleStart or handleStop if triggered. Both of these functions will change the isAnimating state value to false or true.

You will need to stop listening for the events in the useEffect cleanup to prevent unnecessary behavior or memory leaking issues. The Next.js router object makes this simple to do by calling router.events.off(), as opposed to router.events.on(). For clarity, your cleanup function is the following part in the useEffect hook:

return () => {
  router.events.off('routeChangeStart', handleStart)
  router.events.off('routeChangeComplete', handleStop)
  router.events.off('routeChangeError', handleStop)
}

The last step to note is the router argument in the useEffect dependency array. By including router as an argument you're telling useEffect to watch for any changes to the router object. If a change occurs then the useEffect hook will recall the effect functions.

Conclusion

In this lesson, you learned how to create a simple progress bar with a React port of NProgress and how to listen to router events using the useRouter and useEffect hooks. Now when your users request a server-side rendered page with Next.js you can rest assured they're not wondering if their page request is being processed.