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

TerminalShell
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. Mount the provider unconditionally in your root layout:

app/layout.tsxTSX
// 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:

NameForm.tsxTSX
"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:

NoteForm.tsxTSX
"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:

Conditional guardTSX
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

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

types.tsTypeScript
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.

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.jsReactStatus
14.x18Supported
15.x19Supported
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:

Update importsDiff
- 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.