Before Next.js 13, Pages Router was the only solution based on traditional file-system routing and client-side navigation. Starting with Next.js 13, App Routing was implemented as a default replacement.
App Router uses a component-based approach with a focus on Server Components and nested layouts, but in projects based on the Pages Router developers had access to Router.events which exposed routing life-cycle events like routeChangeStart, routeChangeComplete, and routeChangeError. They give you full control over navigation flow: you could show global spinners, log transitions, or (one of most valuable features) cancel navigation when there were unsaved changes.
App Router doesn’t expose any events and moreover affects native events so we as developers are missing an important tool. In this blog, I’ll show you the key changes introduced in Next.js 13’s App Router, explain the challenges that come with the removal of Router.events, and walk you through practical workarounds to restore navigation event handling and functionality.
Changes in NextJS v13 Routing
With the App Router, navigation is no longer just a wrapper around history.pushState and popstate. Instead, Next.js maintains a complex router state machine backed by React Server Components. This state is synchronized with the browser’s history entries, but the history API is essentially hidden behind Next’s own abstractions.
As part of this change:
next/routerandRouter.eventsare no longer available in App Router projects.- browser events like
popstateare intercepted internally by Next.js and may never reach your code. - navigation detection now relies on reactive hooks such as
usePathname()anduseSearchParams().
This ensures consistency for Server Components and data streaming, but takes away the ability to listen to “before navigation” events. That’s why Next.js intercepts popstate and hides low-level events – if developers could cancel navigation arbitrarily, it could desync the router from the streamed server payloads.
What’s Complicated by These Changes
- Global loading indicators – without
routeChangeStartandrouteChangeComplete, it is unclear when to show a spinner during navigation. - Navigation guards – the classic “unsaved changes, are you sure you want to leave?” modal is no longer possible as we used to understand them. At best you can revert after the navigation is already happened.
- Back/Forward button handling –
window.addEventListener("popstate")does not fire reliably (in my practice – it doesn’t fire at all), because Next.js consumes the event internally. - Navigation cancellation – even if you detect a navigation, you cannot prevent it before Next renders the new route
Workaround from the Official Documentation
The official recommendation is to use React hooks provided by next/navigation. For example:
// app/components/navigation-events.js
'use client'
import { useEffect } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'
export function NavigationEvents() {
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
const url = `${pathname}?${searchParams}`
console.log(url)
// You can now use the current URL
// ...
}, [pathname, searchParams])
return '...'
}
// app/layout.js
import { Suspense } from 'react'
import { NavigationEvents } from './components/navigation-events'
export default function Layout({ children }) {
return (
<html lang="en">
<body>
{children}
<Suspense fallback={null}>
<NavigationEvents />
</Suspense>
</body>
</html>
)
}
As we can see, here we can only react to changes in pathname. This is reliable for knowing after a navigation completes, but it doesn’t tell you when it started, nor does it let you stop it.
Events Substitution Workarounds in App Routing
Main directions for these cases are to prevent away navigation if some changes are not saved and to listen navigation events for make UI more friendly and informative.
!Important
This solution doesn’t work:
useEffect(() => {
const handler = () => {
console.log("routeChangeStart (back/forward)");
};
window.addEventListener("popstate", handler);
return () => window.removeEventListener("popstate", handler);
}, []);
That happens because the browser does dispatch popstate when you hit Back/Forward but Next.js App Router attaches its own listener very early (before your app code runs). Inside app-router.client.ts, it consumes that event and sometimes calls event.stopImmediatePropagation() which that means your later window.addEventListener(“popstate”) never runs.
1. Monkey-patching the History API to Restore Navigation Events
By wrapping history.pushState, history.replaceState, and ensuring early popstate listeners are attached in the capture phase, you can dispatch your own custom events that mimic routeChangeStart and routeChangeComplete:
// For purpose of simpliсity this code given as a function
// We can move it to custom hook or into useLayoutEffect in root App component
function patchHistory() {
const rawPush = history.pushState;
const rawReplace = history.replaceState;
history.pushState = function (...args: any[]) {
const result = rawPush.apply(this, args as any);
window.dispatchEvent(new Event("locationchange"));
return result;
};
history.replaceState = function (...args: any[]) {
const result = rawReplace.apply(this, args as any);
window.dispatchEvent(new Event("locationchange"));
return result;
};
const rawAdd = window.addEventListener;
window.addEventListener = function (type, listener, options) {
if (type === "popstate") {
return rawAdd.call(this, type, listener, { capture: true });
}
return rawAdd.call(this, type, listener, options);
};
window.addEventListener("popstate", () => {
window.dispatchEvent(new Event("locationchange"));
});
}
patchHistory();
Now you can listen for locationchange in your app and respond to all navigation, including Back/Forward.
2. Reverting Navigation to Do Cancel-Like Behaviour
Since you cannot cancel navigation before it happens, the option is to implement a watcher to detect the change and immediately restore the old route (extended example from above) and run another helpful logic:
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useRef, useEffect } from "react";
export function NavigationWatcher() {
const pathname = usePathname();
const router = useRouter();
const prevPath = useRef(pathname);
useLayoutEffect(() => {
if (pathname !== prevPath.current) {
// Simplified UI flow just to make it work. We can build complex and beauty UI flow here
const confirmLeave = window.confirm("Leave this page?");
if (!confirmLeave) {
router.push(prevPath.current);
return;
}
}
prevPath.current = pathname;
}, [pathname, enabled, router]);
return null;
}
and we can use it:
import { Suspense } from 'react';
import { NavigationWatcher } from './components/navigation-watcher';
export default function Layout({ children }) {
return (
<html lang="en">
<body>
{children}
<Suspense fallback={null}>
<NavigationWatcher />
</Suspense>
</body>
</html>
)
}
!Important
Component NavigationWatcher should not be unmount on navigation.
It should observe navigation throughout all lifespan of the application so we place it in root layout component.
And one more thing (taken from the documentation):
<NavigationEvents>is wrapped in aSuspenseboundary becauseuseSearchParams()causes client-side rendering up to the closestSuspenseboundary during static rendering. Learn more.
From a user perspective this feels almost like a blocked navigation, though technically the URL changes for a split second before being reverted.
3. Implement a Guard to Do Cancel-Like Behaviour
Another possible solution is to wrap possible target pages (or wrap root layout or modify logic of existing guards) with a custom guard to restore the old route:
function randomPageGuard<P extends object> (Component: ComponentType<P>) {
return function manageNavigation(props: P) {
const pathname = usePathname();
const router = useRouter();
// Hardcoded value or result of some logic
const previousRoute = '/previous-route';
// Some condition goes here
const canNavigate = false;
if (!canNavigate) {
router.push(previousRoute);
return null;
}
return (<Component {...props} />);
};
}
Despite these workarounds, there are still caveats:
- No true cancellation – navigation always happens before your code runs. You can only undo it afterwards.
- Hydration mismatch risk – aggressive monkey-patching of
historymay desync Next’s internal state if their implementation changes in future versions. - Reloads and direct URL typing – when the user enters a new address or reloads the page, this always triggers a full server reload. The only option is the
beforeunloadevent, which shows a generic browser warning. - Future breakage – since these workarounds depend on internals, they may stop working if Next.js changes how it manages history.
- Last but important – when back redirection happens in fact the page is loading one more time. So if there will be some initial logic like API calls or visual effects like animations – they will be start again. In that case we have to check is that logic should be executed and prevent unwanted effects
Conclusion
With the App Router, Next.js has moved routing control deeper into React and its server components system. The upside is powerful streaming and cache management, but the downside is the loss of low-level navigation hooks. For now, developers who need global indicators or guards must rely on monkey-patches and post-navigation checks.
It is known that the community has raised several questions regarding this gap, and it’s possible that a future release will reintroduce a supported router.events-like API.
If you’d like the help of React and Next.js experts on your modern web application, contact us at Trailhead!


