DEV Community

Cover image for Implementing NProgress in the Next.js 13 App Router
Jay @ Designly
Jay @ Designly

Posted on • Originally published at blog.designly.biz

Implementing NProgress in the Next.js 13 App Router

As developers, we strive to create applications that are not just functional but also intuitive and user-friendly. One of the critical aspects that often get overlooked is the user's need for feedback, especially during transitions or while waiting for content to load. This is where loading indicators come into play, serving as a crucial element in Progressive Web Apps (PWAs).

Imagine clicking a button or navigating to a different page in an app and receiving no indication that something is happening. It's akin to pushing a door with no sign of it budging—an experience that can be both confusing and frustrating. This lack of feedback can lead users to question whether the application is working correctly or even prompt them to leave the platform altogether.

Loading indicators, such as progress bars or spinners, provide that much-needed feedback. They inform the user that their request is being processed, offering assurance that the app is functioning as it should. Among the various libraries available for this purpose, NProgress stands out for its simplicity and ease of integration.

There is currently an NPM package called Nextjs Progressbar that nicely implements NProgress. However, this solution only works with the old Pages router and relies on Next/Router. The new App router that many (myself included) are beginning to adopt does not use this library. Router events are no longer available. This is due to Next.js 13's SSR-first design.

That being said, I've created a new NPM package called nextjs13-progress that works perfectly with the new App router!

Here's a rundown of how it works. The first thing is since we have no more router events from good ol' useRouter, we need to call NProgress.start() manually. I did this by creating a wrapper for Next/Link that calls NProgress.start() if we are linking to an internal route. It will automatically detect if the progress bar should be started via shouldTriggerStartEvent.

import NextLink from "next/link";
import { forwardRef } from "react";
import NProgress from "nprogress";

import { shouldTriggerStartEvent } from "./should-trigger-start-event";

export const Link = forwardRef<HTMLAnchorElement, React.ComponentProps<"a">>(function Link(
  { href, onClick, ...rest },
  ref,
) {
  const useLink = href && href.startsWith("/");
  if (!useLink) return <a href={href} onClick={onClick} {...rest} />;

  return (
    <NextLink
      href={href}
      onClick={(event) => {
        if (shouldTriggerStartEvent(href, event)) NProgress.start();
        if (onClick) onClick(event);
      }}
      {...rest}
      ref={ref}
    />
  );
});
Enter fullscreen mode Exit fullscreen mode

Now we can use this version of Link anywhere in our app that links to an App route.

Next, we can call NProgress.done() by creating a suspense boundary in our main layout.tsx:

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

function NProgressDone() {
    const pathname = usePathname();
    const searchParams = useSearchParams();
    useEffect(() => NProgress.done(), [pathname, searchParams]);
    return null;
}

return (
    <Suspense fallback={null}>
        <NProgressDone />
    </Suspense>
)
Enter fullscreen mode Exit fullscreen mode

That's it! Now we have NProgress for the App router! 🎉

Also, if you're curious, here's the code for shouldTriggerStartEvent:

import { addBasePath } from "next/dist/client/add-base-path";

function getURL(href: string): URL {
  return new URL(addBasePath(href), location.href);
}

// https://github.com/vercel/next.js/blob/400ccf7b1c802c94127d8d8e0d5e9bdf9aab270c/packages/next/src/client/link.tsx#L169
function isModifiedEvent(event: React.MouseEvent): boolean {
  const eventTarget = event.currentTarget as HTMLAnchorElement | SVGAElement;
  const target = eventTarget.getAttribute("target");
  return (
    (target && target !== "_self") ||
    event.metaKey ||
    event.ctrlKey ||
    event.shiftKey ||
    event.altKey || // triggers resource download
    (event.nativeEvent && event.nativeEvent.button === 1)
  );
}

export function shouldTriggerStartEvent(href: string, clickEvent?: React.MouseEvent) {
  const current = window.location;
  const target = getURL(href);

  if (clickEvent && isModifiedEvent(clickEvent)) return false; // modified events: fallback to browser behaviour
  if (current.origin !== target.origin) return false; // external URL
  if (current.pathname === target.pathname && current.search === target.search) return false; // same URL

  return true;
}
Enter fullscreen mode Exit fullscreen mode

We need this code to prevent NProgress from starting on things like external links and modified click events (alt, control, shift, etc).


Thanks

Special thanks to # Vũ Văn Dũng for his nextjs13-appdir-router-events demo Next.js project. I borrowed much of the code from that project to make this package.

Links

  1. GitHub Repo
  2. Demo Site
  3. NPM Package

Thank you for taking the time to read my article and I hope you found it useful (or at the very least, mildly entertaining). For more great information about web dev, systems administration and cloud computing, please read the Designly Blog. Also, please leave your comments! I love to hear thoughts from my readers.

If you want to support me, please follow me on Spotify!

Looking for a web developer? I'm available for hire! To inquire, please fill out a contact form.

Top comments (2)

Collapse
 
cibulka profile image
Petr Cibulka

Hello Jay, thanks for the article! I found isModifiedEvent especially helpful.

I don't see that your solution handles the back/forward buttons (or other movement in the history) in any way. Am I missing something? :)

Thank you, P!

Collapse
 
monolithed profile image
A.A

This line if (current.pathname === target.pathname && current.search === target.search) return false; won't work properly with i18n, 'cause '/de/foo' !== '/foo' and '/' !== '/de' are not equal