When a client transitions between routes in a Next.js application, the browser skips the standard hard refresh. It only swaps the relevant content chunks. If your architecture relies heavily on static site generation via getStaticProps or server-side pre-rendering via getServerSideProps, this seamless transition works flawlessly on a pristine connection. But the moment network latency creeps in or a connection hiccups, the interface enters a silent, frozen state. The user clicks a link, nothing moves, and the UI provides zero visual feedback.
To solve this state ambiguity, you need an active visual indicator that communicates progress immediately upon user interaction. While ready-made packages like nextjs-progressbar exist, building an in-house, fully configurable component gives you absolute control over styling, shallow route observation, custom delays, and lifecycle cleanup.
Why Visual Progress Indicators Matter
A progress bar gives the user an immediate visual representation of task completion. It helps them understand how much work has completed and how much remains, allowing them to gauge the approximate time left for an operation to finish. When transitions rely on asynchronous data retrieval or background bundle fetching, an immediate top-level indicator prevents users from repeatedly clicking elements or abandoning the page.
The Architectural Blueprint: Custom NextNProgress
We construct the component by wiring nprogress directly into the Next.js Router.events lifecycle. The component manages route changes, supports shallow route filtering, applies dynamic CSS with optional Content Security Policy (CSP) nonces, and handles cleanup when the component unmounts.
Here is the complete implementation for components/progressbar.tsx:
tsimport Router from 'next/router'; import * as NProgress from 'nprogress'; import * as PropTypes from 'prop-types'; import * as React from 'react'; export interface NextNProgressProps { /** * The color of the bar. * @default "#29D" */ color?: string; /** * The start position of the bar. * @default 0.3 */ startPosition?: number; /** * The stop delay in milliseconds. * @default 200 */ stopDelayMs?: number; /** * The height of the bar. * @default 3 */ height?: number; /** * Whether to show the bar on shallow routes. * @default true */ showOnShallow?: boolean; /** * The other NProgress configuration options to pass to NProgress. * @default null */ options?: Partial<NProgress.NProgressOptions>; /** * The nonce attribute to use for the `style` tag. * @default undefined */ nonce?: string; /** * Use your custom CSS tag instead of the default one. * This is useful if you want to use a different style or minify the CSS. * @default (css) => <style nonce={nonce}>{css}</style> */ transformCSS?: (css: string) => JSX.Element; } const NextNProgress = ({ color = '#003865 linear-gradient(71.18deg, rgb(0, 34, 255)-27.32%, rgb(0, 34, 255)-16.39%, rgb(81, 121, 254)-7.38%, rgb(165, 237, 182) 30.59%, rgb(250, 232, 90) 46.06%, rgb(253, 172, 62) 62.61%, rgb(255, 92, 0) 75.82%);', startPosition = 0.3, stopDelayMs = 200, height = 3, showOnShallow = true, options, nonce, transformCSS = (css) => <style nonce={nonce}>{css}</style>, }: NextNProgressProps) => { let timer: NodeJS.Timeout | null = null; React.useEffect(() => { if (options) { NProgress.configure(options); } Router.events.on('routeChangeStart', routeChangeStart); Router.events.on('routeChangeComplete', routeChangeEnd); Router.events.on('routeChangeError', routeChangeError); return () => { Router.events.off('routeChangeStart', routeChangeStart); Router.events.off('routeChangeComplete', routeChangeEnd); Router.events.off('routeChangeError', routeChangeError); }; }, []); const routeChangeStart = ( _: string, { shallow, }: { shallow: boolean; } ) => { if (!shallow || showOnShallow) { NProgress.set(startPosition); NProgress.start(); } }; const routeChangeEnd = ( _: string, { shallow, }: { shallow: boolean; } ) => { if (!shallow || showOnShallow) { if (timer) clearTimeout(timer); timer = setTimeout(() => { NProgress.done(true); }, stopDelayMs); } }; const routeChangeError = ( _err: Error, _url: string, { shallow, }: { shallow: boolean; } ) => { if (!shallow || showOnShallow) { if (timer) clearTimeout(timer); timer = setTimeout(() => { NProgress.done(true); }, stopDelayMs); } }; return transformCSS(`#nprogress{pointer-events:none}#nprogress .bar{background:${color};position:fixed;z-index:9999;top:0;left:0;width:100%;height:${height}px}#nprogress .peg{display:block;position:absolute;right:0;width:100px;height:100%;box-shadow:0 0 10px ${color},0 0 5px ${color};opacity:1;-webkit-transform:rotate(3deg) translate(0,-4px);-ms-transform:rotate(3deg) translate(0,-4px);transform:rotate(3deg) translate(0,-4px)}#nprogress .spinner{display:block;position:fixed;z-index:1031;top:15px;right:15px}#nprogress .spinner-icon{width:18px;height:18px;box-sizing:border-box;border:solid 2px transparent;border-top-color:${color};border-left-color:${color};border-radius:50%;-webkit-animation:nprogresss-spinner 400ms linear infinite;animation:nprogress-spinner 400ms linear infinite}.nprogress-custom-parent{overflow:hidden;position:relative}.nprogress-custom-parent #nprogress .spinner,.nprogress-custom-parent #nprogress .bar{position:absolute}@-webkit-keyframes nprogress-spinner{0%{-webkit-transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg)}}@keyframes nprogress-spinner{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}`); }; NextNProgress.propTypes = { color: PropTypes.string, startPosition: PropTypes.number, stopDelayMs: PropTypes.number, height: PropTypes.number, showOnShallow: PropTypes.bool, options: PropTypes.object, nonce: PropTypes.string, transformCSS: PropTypes.func, }; export default React.memo(NextNProgress);
Component Breakdown and Configuration Options
The NextNProgressProps interface gives fine-grained control over both the visual presentation and the execution logic during routing events.
| Property | Type | Default Value | Description |
|---|---|---|---|
color | string | Multi-stop gradient | Sets the CSS background property for the loading bar and peg shadows. |
startPosition | number | 0.3 | The initial percentage value where the progress bar starts running. |
stopDelayMs | number | 200 | Delay in milliseconds before executing NProgress.done() to prevent flickers. |
height | number | 3 | Height of the progress bar in pixels. |
showOnShallow | boolean | true | Determines whether the progress indicator triggers on shallow route updates. |
options | Partial<NProgress.NProgressOptions> | undefined | Custom configuration payload passed directly into NProgress.configure(). |
nonce | string | undefined | Nonce attribute applied to the generated <style> tag for strict CSP environments. |
transformCSS | (css: string) => JSX.Element | Injected <style> tag | Function allowing custom CSS transformations, minification, or external injection. |
Integration into the Application Layout
Once the component is built, integrate it at the layout level so it persists across page navigations. In your layout structure (such as pages/layout/index.tsx), mount the component high in the tree.
jsimport Header from "../../components/header" import Footer from "../../components/footer" //import nextprogresss compoonent import NextNProgress from '../../components/nprogress'; const Layout = ({ categories, children }: Props) => { return ( <> <div className="relative h-screen bg-gradient-to-b-white dark:bg-gradient-to-b lg:h-[140vh]"> <NextNProgress options={{ showSpinner: false }} /> <Header categories={categories} /> <main>{children}</main> <Footer /> </div> </> ) } export default Layout;
With NextNProgress placed at the top of your layout container, navigating to any route immediately triggers the top bar. The component binds to Router.events, starts the animation at 0.3, watches for completion or errors, and clears timers gracefully on unmount. This small architectural addition eliminates dead air between client transitions and delivers unambiguous feedback on every route change.
