You use Next.js, and you want to show "You have unsaved changes that will be lost." dialog when user leaves page? This library is just for you!
https://layerxcom.github.io/next-navigation-guard/
npm install next-navigation-guard
# or
yarn install next-navigation-guard
# or
pnpm install next-navigation-guard-
App Router: app/layout.tsx
<html lang="en"> <body className={`${geistSans.variable} ${geistMono.variable}`}> <NavigationGuardProvider>{children}</NavigationGuardProvider> </body> </html>
-
Page Router: page/_app.tsx
export default function MyApp({ Component, pageProps }: AppProps) { return ( <NavigationGuardProvider> <Component {...pageProps} /> </NavigationGuardProvider> ); }
-
window.confirm()
useNavigationGuard({ enabled: form.changed, confirm: () => window.confirm("You have unsaved changes that will be lost.") })
-
Custom dialog component
const navGuard = useNavigationGuard({ enabled: form.changed }) return ( <> <YourContent /> <Dialog open={navGuard.active}> <DialogText>You have unsaved changes that will be lost.</DialogText> <DialogActions> <DialogButton onClick={navGuard.reject}>Cancel</DialogButton> <DialogButton onClick={navGuard.accept}>Discard</DialogButton> </DialogActions> </Dialog> </> )
See working example in example/ directory and its NavigationGuardToggle component.
Pass disableForTesting to NavigationGuardProvider and the library will skip its host-environment hooks (no popstate / beforeunload / click listeners, no window.history augmentation). The App Router / Pages Router context overrides remain in place, so navigation through Next.js routers (incl. mock routers in tests) still evaluates registered guards. useNavigationGuard keeps registering normally, so the enabled predicate, the confirm callback, and active / accept / reject work as in production.
// each guard's own confirm runs as in production
render(
<NavigationGuardProvider disableForTesting>
<MyComponent />
</NavigationGuardProvider>
);To bypass the confirmation UI entirely in tests (and assert which navigations were attempted), pass mockConfirm. It replaces every registered guard's confirm during evaluation; per-guard enabled predicates still run.
const mockConfirm = jest.fn().mockResolvedValue(true);
render(
<NavigationGuardProvider disableForTesting={{ mockConfirm }}>
<MyComponent />
</NavigationGuardProvider>
);
// drive navigation through your mock router…
expect(mockConfirm).toHaveBeenCalledWith({ to: "/foo", type: "push" });