nextjs-nav-guard

Prevent accidental navigation away from unsaved changes in Next.js App Router. Zero config. Two lines of code.

v1.0.9·19,964 downloads·MIT license

Install

npm install nextjs-nav-guard

What 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. Wrap your app with the provider 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:

import { useNavigationGuard } from "nextjs-nav-guard";

function MyForm() {
  const [isDirty, setIsDirty] = useState(false);

  useNavigationGuard({
    enabled: isDirty,
    confirm: () => window.confirm("You have unsaved changes. Leave anyway?"),
  });

  return <form>{/* your form */}</form>;
}

That's it. Two imports, two lines of setup.

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:

import { useNavigationGuard } from "nextjs-nav-guard";

function MyForm() {
  const [isDirty, setIsDirty] = useState(false);
  const guard = useNavigationGuard({ enabled: isDirty });

  return (
    <>
      <form>{/* your form */}</form>

      {guard.active && (
        <Dialog open>
          <p>You have unsaved changes. Leave anyway?</p>
          <button onClick={guard.reject}>Stay</button>
          <button onClick={guard.accept}>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.

Options

OptionTypeDefaultDescription
enabledboolean | (params) => booleantrueWhether the guard is active. Can be a function receiving navigation params.
confirm(params) => boolean | Promise<boolean>undefinedConfirmation callback. Return true to allow, false to block. If omitted, uses async mode.
disableForTestingbooleanfalseMakes the hook a no-op. No provider required. Use in tests and Storybook.

Return value

PropertyTypeDescription
activebooleantrue when a navigation attempt is pending confirmation (async mode only).
accept() => voidAllow the pending navigation.
reject() => voidBlock the pending navigation.

Navigation params

Both enabled (when a function) and confirm receive:

PropertyTypeDescription
tostringThe 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";

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.

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. Code that relies on that click's normal propagation may need to account for this.

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.jsReactStatus
14.x18, 19Supported
15.x18, 19Supported
16.x19Supported. 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.