Global progress in Next.js

Sam Selikoff

Sam Selikoff

Ryan Toronto

Ryan Toronto

Introduction

The new App Router in Next.js doesn't expose router events, which makes it tricky to wire up libraries like NProgress to show global pending UI during page navigations.

The following demo uses a transition-aware Hook and a custom link component to show a progress bar while new pages are being loaded.

Try clicking the links to see it in action:

The progress starts when a link is clicked and completes when the server responds with the new page. Because navigations in Next.js are marked as Transitions, the existing UI remains visible – and interactive – while the update is pending.

Show me the code!

Drop <ProgressBar> in a Layout, and any <ProgressBarLink> that's rendered as a child will animate the bar when clicked.

import { ReactNode } from "react";
import { ProgressBarLink, ProgressBar } from "./progress-bar";

export default function Layout({ children }: { children: ReactNode }) {
  return (
    <div>
      <ProgressBar className="fixed top-0 h-1 bg-sky-500" />
        <header className="border-b border-gray-700">
          <nav className="m-4 flex gap-4">
            <ProgressBarLink href="/demo-1">Home</ProgressBarLink>
            <ProgressBarLink href="/demo-1/posts/1">Post 1</ProgressBarLink>
            <ProgressBarLink href="/demo-1/posts/2">Post 2</ProgressBarLink>
            <ProgressBarLink href="/demo-1/posts/3">Post 3</ProgressBarLink>
          </nav>
        </header>

        <div className="m-4">{children}</div>
      </ProgressBar>
    </div>
  );
}

The progress bar is powered by a custom useProgress Hook. The Hook returns a Motion Value that's used to animate the bar's width, as well as start and done functions called by <ProgressLink>.

Since navigations in the App Router are marked as Transitions, the link uses startTransition to call done alongside router.push. Once the new page is ready and the UI has been updated, the bar completes its animation.


Let's learn how it works by first looking at our custom useProgress Hook!

The useProgress Hook

useProgress exposes five values:

const { start, done, reset, state, value } = useProgress();

start, done, and reset control the progress's state; state is a string representing the current state of the Hook; and value is a Motion Value (from Framer Motion) that handles the animation.

Here's a simple UI wired up to useProgress. Try pressing the buttons to see the behavior:

Once you press Start, the Hook kicks off an interval that periodically animates value towards 100 – without ever reaching it. Pressing Done moves the Hook to the completing state, which animates the value all the way to 100.

As soon as value reaches 100, the Hook's state updates to complete. From there, you can reset the Hook back to its initial state, or press Start to kick off the cycle all over again.

You can also press Done from the initial state to animate value from 0 directly to 100, bypassing the in-progress state altogether.

Let's see how to animate some UI with our new Hook!

Rendering animated loaders

Now that we have a Motion Value that animates from 0 to 100, we can use it with any motion element.

For example, we could render a spinner:

Or a horizontal progress bar:

If we want to automatically fade out the bar when the progress completes, we can use <AnimatePresence> to add an exit animation:

Calling reset after our progress fades out puts it back into its initial state, so we can immediately click Start or Done to kick off a new cycle.

In fact, if we fix the bar to the top of the screen and drop the Reset button, we have exactly what we need for the UI from our final demo:

All that's left is to wire up the calls to start and done to link navigations in Next.js. Let's see how to do it!

Showing progress during navigations

Like we said at the beginning of this post, the App Router dropped support for router.events, which was a feature of the Pages Router. If it still exposed those events, we could just call start and done in response to the routeChangeStart and routeChangeComplete events, and call it a day.

Instead, the intended way to extend the behavior of next/link in the App Router is to make our own custom Link component that wraps it.

So, let's start by creating a new component. We'll call it <ProgressLink>, since clicking it will eventually trigger our global progress bar.

We'll give it an href prop and children, and have it return next/link:

// app/components/progress-link.tsx
"use client";

import Link from "next/link";
import { ReactNode } from "react";

export default function ProgressLink({
  href,
  children,
}: {
  href: string;
  children: ReactNode;
}) {
  return (
    <Link href={href}>
      {children}
    </Link>
  );
}

Great! So far it works just like <Link>.

Next, let's prevent the default behavior by calling e.preventDefault in onClick:

export default function ProgressLink({
  href,
  children,
}: {
  href: string;
  children: ReactNode;
}) {
  return (
    <Link
      onClick={(e) => { 
        e.preventDefault(); 
      }} 
      href={href}
    >
      {children}
    </Link>
  );
}

<ProgressLink> is now inert – clicking it doesn't cause a navigation.

But, if we grab the router from the useRouter Hook, we can programmatically navigate to our href prop using router.push:

import { useRouter } from "next/navigation";

export default function ProgressLink({
  href,
  children,
}: {
  href: string;
  children: ReactNode;
}) {
  let router = useRouter(); 

  return (
    <Link
      onClick={(e) => {
        e.preventDefault();
        router.push(href); 
      }}
      href={href}
    >
      {children}
    </Link>
  );
}

Cool! Our link's working again, but now we have programmatic control over the navigation.

So – when should we start and stop our progress?

You might be thinking that router.push() returns a Promise, in which case we should be able to do something like this:

export default function ProgressLink({
  href,
  children,
}: {
  href: string;
  children: ReactNode;
}) {
  let router = useRouter();
  let { start, done } = useProgress(); 

  return (
    <Link
      onClick={async (e) => {
        e.preventDefault();

        start(); 
        await router.push(href); 
        done(); 
      }}
      href={href}
    >
      {children}
    </Link>
  );
}

But it doesn't. Its return type is void, meaning it doesn't return anything. And there's no state to read from the router itself to cue us in to the fact that a navigation is underway.

So – how the heck can we tell when a navigation is finished?

It turns out that the App Router is built on React Transitions. Transitions were introduced in React 18, and even though they've been out for about two years, they're still making their way into libraries and frameworks throughout the ecosystem.

And conceptually, Transitions are very similar to Promises. They represent some potential future value – although instead of any JavaScript value, that future value is a React tree; and they also exist in either a pending or a settled state.

But how we use them is a bit different. Let's see how with a simple example.

How Transitions work

Suppose we wanted to show how many times each <ProgressLink> has been clicked. Let's define some new React State called count, increment it on click, and display it next to our link's label:

export default function ProgressLink({
  href,
  children,
}: {
  href: string;
  children: ReactNode;
}) {
  let router = useRouter()
  let [count, setCount] = useState(0); 

  return (
    <Link
      onClick={async (e) => {
        e.preventDefault();

        setCount(c => c + 1);
        router.push(href);
      }}
      href={href}
    >
      {children} {count}
    </Link>
  );
}

Simple enough. Let's check out the behavior:

If you start at Home and click Page 1, you'll see the count update immediately. Then, once Page 1 is loaded, the rest of the UI updates.

Now check this out. Let's wrap our calls to setCount and router.push inside of startTransition, a function provided by React:

import { startTransition } from 'react'; 

export default function ProgressLink({
  href,
  children,
}: {
  href: string;
  children: ReactNode;
}) {
  let router = useRouter()
  let [count, setCount] = useState(0);

  return (
    <Link
      onClick={async (e) => {
        e.preventDefault();

        startTransition(() => { 
          setCount(c => c + 1); 
          router.push(href); 
        }); 
      }}
      href={href}
    >
      {children} {count}
    </Link>
  );
}

Check out the behavior now:

Isn't that wild?

If you start out at Home, refresh, and then click Page 1, the count doesn't update until router.push has finished loading the new page. The entire UI updates together with all the new state.

So, when we kick off a Transition with one or more State updates:

startTransition(() => { 
  setCount(c => c + 1); 
  router.push(href); 
}); 

React attempts to run all of the updates together, in the background. If any of those updates suspend – which in our case, router.push does – React will hold off applying the updates to our current UI until the new tree is fully ready to be rendered.

You can think of the new tree that's being prepared as a fork of our current UI. React keeps it in the background until it's fully ready, at which point it's merged back into our main UI, and we see the updates committed to the screen.

Tracking navigations

Now that we have a way to set some state once router.push has finished, let's replace our count state with an isNavigating boolean:

export default function ProgressLink({
  href,
  children,
}: {
  href: string;
  children: ReactNode;
}) {
  let router = useRouter()
  let [isNavigating, setIsNavigating] = useState(false); 

  return (
    <Link
      onClick={async (e) => {
        e.preventDefault();

        router.push(href);
      }}
      href={href}
    >
      {children}
    </Link>
  );
}

Next, let's use a normal state update to set isNavigating to true when we click, and flip it back to false inside of a Transition:

export default function ProgressLink({
  href,
  children,
}: {
  href: string;
  children: ReactNode;
}) {
  let router = useRouter();
  let [isNavigating, setIsNavigating] = useState(false);

  return (
    <Link
      onClick={(e) => {
        e.preventDefault();
        setIsNavigating(true); 

        startTransition(() => { 
          router.push(href); 
          setIsNavigating(false); 
        }); 
      }}
      href={href}
    >
      {children}
    </Link>
  );
}

Our isNavigating state should now track our navigations!

Let's update the label to show three dots while isNavigating is true:

<Link
  onClick={(e) => {
    e.preventDefault();
    setIsNavigating(true);

    startTransition(() => {
      router.push(href);
      setIsNavigating(false);
    });
  }}
  href={href}
>
  {children} {isNavigating ? "..." : ""}
</Link>

and give it a shot!

The dots render while the navigation is in progress, and disappear as soon as the new page is ready.

...meaning we now know exactly when to start and stop our progress bar!

Rendering the progress bar

Let's bring our <ProgressBar> back into our Layout, and pass the start and done functions into our links as props:

export default function Layout({ children }: { children: ReactNode }) {
  let { value, state, start, done, reset } = useProgress();

  return (
    <div>
      <AnimatePresence onExitComplete={reset}>
        {state !== "complete" && (
          <motion.div exit={{ opacity: 0 }} className="w-full">
            <ProgressBar progress={value} />
          </motion.div>
        )}
      </AnimatePresence>

      <nav>
        <ProgressLink start={start} done={done} href="/demo-8-progress-link">
          Home
        </ProgressLink>
        <ProgressLink start={start} done={done} href="/demo-8-progress-link/1">
          Page 1
        </ProgressLink>
        <ProgressLink start={start} done={done} href="/demo-8-progress-link/2">
          Page 2
        </ProgressLink>
        <ProgressLink start={start} done={done} href="/demo-8-progress-link/3">
          Page 3
        </ProgressLink>
      </nav>

      <div className="m-4">{children}</div>
    </div>
  );
}

Inside our <ProgressLink>, we can call start() immediately and done() inside the Transition:

<Link
  onClick={(e) => {
    e.preventDefault();
    start(); 

    startTransition(() => {
      router.push(href);
      done(); 
    });
  }}
  href={href}
>
  {children}
</Link>

The progress bar should now track our navigations.

Let's give it a shot:

Booyah!

Whenever we click one of our links, the <ProgressBar> starts animating, and when the new page finishes loading, our app updates, and the progress bar completes its animation.

Interruptibility

One neat benefit that falls out of Transitions is how our <ProgressLink> handles interruptions.

Try quickly pressing Page 1 and then Page 2:

Notice how our app keeps the home page rendered – and the progress bar animating – without a flicker. Once Page 2 is ready, everything updates in one seamless re-render.

Pretty cool right? We got that behavior for free without even considering it, thanks to the behavior of Transitions.

This is because Transitions are considered "low-priority updates":

<Link
  onClick={(e) => {
    e.preventDefault();
    // Normal "high-priority" update
    start();

    startTransition(() => {
      // "Low-priority" updates
      router.push(href); 
      done(); 
    });
  }}
  href={href}
>
  {children}
</Link>

What this means is that any Transition has the potential to be discarded. If a new Transition is started that updates the same state of a currently pending Transition, the old update will be ignored.

You can think of it like this:

/*
  Clicking Page 1 does this...
*/
start();
startTransition(() => {
  router.push("/1");
  done();
});

/*
  ...and interrupting Page 1 by clicking Page 2 does this.
  The new Transition takes precedence over the first one.
*/
start();
startTransition(() => {
  router.push("/2");
  done();
});

Since our useProgress.start function can be called repeatedly without disrupting the in-progress animation, our UI avoids any unnecessary re-renders. Only once the second Transition settles is our done() function applied to the current UI.

Refactoring with Context

Our new <ProgressLink> components work great, but there's a lot of prop passing going on. You can imagine that getting start and done to every link in our app could get pretty annoying, pretty fast.

Let's extract our layout's <ProgressBar> into a provider component. It will still render the animated progress bar, but it will also use Context to provide start and done directly to <ProgressLink>.

Awesome! Works just like before. And now <ProgressLink> has the same API as next/link, making it much easier to use throughout our app.

As an added bonus, our layout is back to being a Server Component, since the client code is only needed in the provider and links. This will make it easier to add data fetching to our layout, should the need arise in the future.

Why doesn't Next.js expose router events?

We've built a pretty neat <ProgressLink> component that works well with React Transitions and the Next.js router – but admittedly, it was quite a bit of work. You might be wondering, why doesn't Next's new App Router just expose global navigation events and make this entire problem a whole lot easier?

The answer: Composition.

Imagine the router did expose global events. We wire up our progress bar to animate on any navigation, and the good ol' <Link> component from Next works out of the box, showing our global progress any time its used.

...but then someone comes along and builds a <Messenger> component that's docked to the bottom of the page:

Oops.

If you click Sam or Ryan in the messenger, you'll see our global progress bar show up. Probably not what they intended.

<Messenger> happens to use links as part of its implementation:

"use client";

export default function Messenger() {
  return (
    <div className="fixed bottom-0 right-3">
      <p>Messages</p>

      <div className="mt-3">
        <Link href={`${pathname}?user=sam`}>Sam</Link>
        <Link href={`${pathname}?user=ryan`}>Ryan</Link>
      </div>
    </div>
  );
}

but because our <ProgressBar> is wired up to Next's global events, this new component – which was supposed to be an isolated component – is now triggering some global UI.

This is the sort of refactoring hazard that the App Router is designed to help us avoid. By not exposing any APIs that can affect every usage of <Link>, Next.js is shielding us from this sort of "spooky action at a distance", and ensuring that any new feature we build using the framework's core primitives can be built in complete isolation from the rest of our app.

Back in the App Router, the author of <Messenger> can just use next/link, and our progress never gets triggered:

Nice.

So, lack of global behavior is a common feature across Next's APIs. But it's not just about avoiding refactoring hazards...

Let's say the author of <Messenger> saw our <ProgressBar> and wanted to try it out in their new component.

No problem:

The <Messenger> renders its own <ProgressBar>, and since useContext reads from the nearest provider, its <ProgressBarLink> components update the state of its local progress bar, rather than the one in the root layout.

Very cool!

Framework-agnostic components

If you look at our final useProgress Hook and <ProgressBar> component, you'll notice that neither of them depend on anything from Next.js. This means that they can be used in any React app, regardless of the framework.

Even more, our Hook is robust to interruption, so it can be used to show pending UI for any state update marked as a Transition – including updates that have nothing to do with navigations.

Finally, because we were able to encapsulate all our logic and behavior inside of a React component, our <ProgressBar> is composable. Even if we start out by using it to render a "global" indicator near the root of our app, we can use it again and again throughout our tree, and none of the instances will disrupt each other.

It's a neat example of how React's design kept nudging us until we landed on a composable API, in spite of starting this whole journey by trying to add a single global progress bar to our app.


Transitions are incredibly powerful. If you haven't had a chance to use them, check out the docs on useTransition and play around with the interactive demos.

I learned a ton writing this article and came away from it feeling incredibly excited about React's future. I hope you did too – and thank you so much for reading!

You can find the source for all the demos on GitHub.

Last updated:

Get our latest in your inbox.

Join our newsletter to hear about Sam and Ryan's newest blog posts, code recipes, and videos.