mikhilmistry

A top progress bar for the Next.js App Router

You have seen this bar a thousand times. A thin line slides across the top of the page while the next screen loads, then snaps to full width and fades out. YouTube has it. GitHub has it. This site has it too. Look up while you click between pages.

It is a small thing, but it does real work: it tells the reader something is happening in the fraction of a second before the new page paints, so a click never feels ignored.

The classic implementation is NProgress, a tiny library with no dependencies. In the old Next.js Pages Router you wired it to router.events. The App Router removed those events, so the usual recipes no longer apply. Here is a version that works, and the one gotcha that will waste your afternoon if you miss it.

The pieces

Install the library and its types:

npm install nprogress
npm install --save-dev @types/nprogress

NProgress works by injecting a #nprogress element into the DOM when you call start(), and removing it when you call done(). All we need is to call those two functions at the right moments.

A client component

The App Router gives you usePathname, which changes once navigation completes. That is our signal to finish the bar. To start it, we listen for clicks on internal links. This lives in its own client component:

"use client";

import { useEffect } from "react";
import { usePathname } from "next/navigation";
import NProgress from "nprogress";

NProgress.configure({ showSpinner: false });

export default function TopProgress() {
  const pathname = usePathname();

  // The path changed, so navigation is done. Finish the bar.
  useEffect(() => {
    NProgress.done();
  }, [pathname]);

  // Start the bar the moment an internal link is clicked.
  useEffect(() => {
    function onClick(e: MouseEvent) {
      if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
        return;
      }
      const anchor = (e.target as HTMLElement)?.closest("a");
      const href = anchor?.getAttribute("href");
      if (!href || !href.startsWith("/") || anchor?.target === "_blank") {
        return;
      }
      if (href.replace(/\/$/, "") !== pathname.replace(/\/$/, "")) {
        NProgress.start();
      }
    }

    document.addEventListener("click", onClick);
    return () => document.removeEventListener("click", onClick);
  }, [pathname]);

  return null;
}

Then drop it once, high in your tree, so it is present on every page:

// app/layout.tsx
import TopProgress from "./top-progress";

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <TopProgress />
        {children}
      </body>
    </html>
  );
}

The gotcha

Here is the part that cost me an afternoon. My first version began the click handler like this:

function onClick(e: MouseEvent) {
  if (e.defaultPrevented) return; // seems reasonable, right?
  // ...
}

That looks careful. Skip the click if something already handled it. But the bar never appeared, and no error was thrown.

The reason: Next's <Link> calls preventDefault() on the click itself, so it can do a client-side navigation instead of a full page load. React's handler runs before a plain document listener, so by the time my code ran, e.defaultPrevented was already true. My own guard was throwing the event away every single time.

The fix is simply to not bail on defaultPrevented. For a Next link, the default being prevented is exactly the case we care about:

function onClick(e: MouseEvent) {
  // Do NOT check e.defaultPrevented here. Next's <Link> prevents the
  // default to run a client-side navigation, which is precisely when we
  // want the bar to run.
  if (e.button !== 0 || e.metaKey || e.ctrlKey) return;
  // ...
}

Keep the modifier-key checks, though. Those catch open-in-new-tab and middle-click, where the current page does not navigate and the bar should stay put.

Styling it

NProgress ships a default stylesheet, but it is more satisfying to write your own. The bar is #nprogress .bar, and the little glowing comet tail at its leading edge is #nprogress .peg. Here it is in this site's yellow:

#nprogress .bar {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 1031;
  width: 100%;
  height: 3px;
  background: #ffe080;
}

#nprogress .peg {
  display: block;
  position: absolute;
  right: 0;
  width: 100px;
  height: 100%;
  box-shadow: 0 0 10px #ffe080, 0 0 5px #ffe080;
  transform: rotate(3deg) translate(0, -4px);
}

Why bother

Perceived performance is still performance. The page is not any faster, but the reader is told, instantly, that their click landed. That reassurance is the whole point, and it costs about forty lines and zero runtime dependencies of your own.

Click a link in the header and watch the top of the page. That is all of it.