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.