nextjs-nav-guard
Prevent accidental navigation away from unsaved changes in Next.js App Router. Protect forms and editors with a provider and a hook.
Install
npm install nextjs-nav-guardWhat it intercepts
Router methods
router.push(), router.replace(), router.refresh()
Link clicks
Next.js <Link> and plain <a> tags
Browser navigation
Back/forward buttons, history.go()
Page unload
Tab close, window.location changes
Quick start
1. Mount the provider unconditionally in your root layout:
// app/layout.tsx
import { NavigationGuardProvider } from "nextjs-nav-guard";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<NavigationGuardProvider>{children}</NavigationGuardProvider>
</body>
</html>
);
}2. Use the hook in any component with unsaved changes:
"use client";
import { useState } from "react";
import { useNavigationGuard } from "nextjs-nav-guard";
export default function NameForm() {
const [name, setName] = useState("");
useNavigationGuard({
enabled: name !== "",
confirm: () => window.confirm("Discard your changes?"),
});
return (
<label>
Name
<input value={name} onChange={(event) => setName(event.target.value)} />
</label>
);
}Mount the provider unconditionally. Put loading screens and session checks inside it so browser history protection is installed early.
Custom dialog UI
Omit the confirm callback to use async mode. The hook returns active, accept, and reject so you can render your own confirmation dialog:
"use client";
import { useEffect, useRef, useState } from "react";
import { useNavigationGuard } from "nextjs-nav-guard";
export default function NoteForm() {
const [note, setNote] = useState("");
const dialogRef = useRef<HTMLDialogElement>(null);
const guard = useNavigationGuard({ enabled: note !== "" });
useEffect(() => {
const dialog = dialogRef.current;
if (guard.active && dialog && !dialog.open) dialog.showModal();
if (!guard.active && dialog?.open) dialog.close();
}, [guard.active]);
return (
<>
<label>
Note
<textarea value={note} onChange={(event) => setNote(event.target.value)} />
</label>
<dialog ref={dialogRef} aria-label="Discard your changes?" onCancel={(event) => {
event.preventDefault();
guard.reject();
}}>
<p>You have unsaved changes.</p>
<button onClick={guard.reject}>Keep editing</button>
<button onClick={guard.accept}>Discard and leave</button>
</dialog>
</>
);
}Conditional guard
The enabled option accepts a function that receives the navigation type, so you can guard selectively:
useNavigationGuard({
enabled: ({ type }) => {
// Only guard against link clicks and back/forward, not refresh
return type !== "refresh" && type !== "beforeunload";
},
confirm: () => window.confirm("Discard changes?"),
});API reference
<NavigationGuardProvider>
Wrap your app with this provider in your root layout. No props required other than children. It sets up interception of all navigation methods listed above.
useNavigationGuard(options)
Register a navigation guard. Returns an object with active, accept, and reject.
This is the JavaScript API for the npm library. It runs in your application and requires no hosted service or API key.
Options
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | (params) => boolean | true | Whether the guard is active. Can be a function receiving navigation params. |
confirm | (params) => boolean | Promise<boolean> | undefined | Confirmation callback. Return true to allow, false to block. If omitted, uses async mode. |
disableForTesting | boolean | false | Makes the hook a no-op. No provider required. Use in tests and Storybook. |
Return value
| Property | Type | Description |
|---|---|---|
active | boolean | true when a navigation attempt is pending confirmation (async mode only). |
accept | () => void | Allow the pending navigation. |
reject | () => void | Block the pending navigation. |
Navigation params
Both enabled (when a function) and confirm receive:
| Property | Type | Description |
|---|---|---|
to | string | The target URL. |
type | "push" | "replace" | "refresh" | "popstate" | "beforeunload" | How the navigation was triggered. |
Type exports
import type {
NavigationGuard, // (params: NavigationGuardParams) => boolean | Promise<boolean>
NavigationGuardOptions, // { enabled?, confirm?, disableForTesting? }
NavigationGuardParams, // { to: string; type: "push" | "replace" | ... }
} from "nextjs-nav-guard";Build this with us
Maintained by Brett Schneider, based on the work of LayerX and its contributors. Reproductions, tests, and documentation are welcome.
Links with options and callbacks
Version note: This component and the navigation concurrency fixes below require version 1.1.0 or later. They are not included in 1.0.9. See the changelog for release details.
Use NavigationGuardLink to preserve replacement history, scroll control, and click or navigation callbacks. Existing guards work with this component.
import { NavigationGuardLink } from "nextjs-nav-guard";
export default function SettingsLink() {
return <NavigationGuardLink href="/settings" replace scroll={false}>Settings</NavigationGuardLink>;
}The first pending navigation owns the confirmation. Further attempts are blocked until it settles. Unmounting or disabling a guard cancels its pending attempt.
Starting with version 1.1.1, changes to the registered guards cancel the pending attempt when confirmation finishes. Start a new navigation to check the current guards. Accepted links also preserve a configured Next.js basePath without adding it twice.
For saving before navigation, see the complete form demo. Only mark the form clean after saving succeeds.
Limitations
Reloads and tab closes use the browser dialog
Custom dialog UIs only work for client-side navigations. When the browser fires beforeunload (page reload, tab close, leaving for another site), browsers do not allow async work or custom UI. The library can only request the browser's built-in confirmation dialog, and its text and appearance cannot be customized.
Safari can bypass external-navigation prompts
Safari 27.0 and Playwright WebKit 26.5 allowed cross-site navigation without firing beforeunload in our checks, including on a plain HTML page without this library. Tab-close confirmation passed separately. Save drafts independently when losing work would be costly; unload protection is not guaranteed for external links or address-bar navigation.
Direct History API calls are not guarded
Calls made directly through window.history.pushState() or window.history.replaceState() bypass the guard. If you call either method yourself, confirm the navigation before calling it.
Guarded link clicks are handled programmatically
To intercept <Link> and <a> clicks, the provider registers a capture-phase click handler. While a guard is enabled, that handler prevents the original click and stops its propagation while the confirmation is pending, then navigates via the App Router if accepted. Use NavigationGuardLink when you need replace, scroll, or click and navigation callbacks. The automatic interceptor cannot recover these React props.
Provider placement matters
Keep the provider mounted in the root layout. Mounting it after a loading screen or async session check can let Next.js handle Back and Forward before the guard. Put conditional content inside the provider.
Next.js 16.2 drops query-only replacements after an async guard
Next.js 16.2 has an App Router regression: after an async guard is accepted, a router.replace() that changes only the query string may be dropped. This is a Next.js bug, fixed in Next.js 16.3.0, and the library cannot safely work around it. If your app uses this pattern, stay on 16.1.x or upgrade to Next.js 16.3.0 or later.
Compatibility
| Next.js | React | Status |
|---|---|---|
| 14.x | 18 | Supported |
| 15.x | 19 | Supported |
| 16.x | 19 | Supported. 16.2 has a known Next.js router bug, see limitations. |
Migrating from next-navigation-guard
The API is identical. Just change the import:
- import { NavigationGuardProvider, useNavigationGuard } from "next-navigation-guard";
+ import { NavigationGuardProvider, useNavigationGuard } from "nextjs-nav-guard";If you were using Pages Router, you'll need to switch to App Router. Pages Router support has been removed.