Skip to content

QuaduxIT/next-stay

Repository files navigation

Quadux IT Logo

next-stay

CI npm version GitHub License: Apache 2.0

English | Deutsch

Robust navigation guard for Next.js 15.3+ App Router. Protects against unsaved changes across all navigation types:

  • Client-side navigation (<Link>, router.push(), router.replace())
  • Browser Back/Forward (via popstate + Navigation API)
  • Tab close / Reload (via beforeunload)
  • Custom confirmation dialogs (not limited to native confirm())

Why next-stay?

Next.js doesn't provide a built-in way to prevent navigation when users have unsaved changes. The native beforeunload event only covers page reloads and tab closes - it doesn't fire on client-side SPA navigation. And while Next.js 15.3 introduced the onNavigate prop on <Link>, it only covers link clicks, not programmatic navigation or the browser back/forward buttons.

next-stay closes all these gaps with a simple API - one component in your layout, one hook in your form.

Approach

next-stay was built from scratch for Next.js 15.3+ and takes a different approach than existing solutions:

  • Uses Next.js's official onNavigate prop on <Link> - no internal imports
  • Wraps the router via hooks instead of patching context
  • Covers router.back() / router.forward() via Navigation API + popstate fallback
  • Works with React 19 and Strict Mode
  • Zero dependencies beyond React and Next.js
  • No provider required - uses a global store with useSyncExternalStore

Alternative: next-navigation-guard is another great library that solves the same problem. Check it out if you're looking for a more established option.

Installation

npm install next-stay

Quick Start

1. Wrap your layout with StayProvider

Add <StayProvider> to your root layout to enable back/forward and tab-close protection:

import { StayProvider } from "next-stay";

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <StayProvider>{children}</StayProvider>
      </body>
    </html>
  );
}

2. Guard a Form

"use client";

import { useState } from "react";
import { useStay } from "next-stay";

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

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

  return (
    <form>
      <input onChange={() => setIsDirty(true)} />
      <button type="submit">Save</button>
    </form>
  );
}

That's it. No provider wrapping needed.

3. Use StayLink (optional)

Drop-in replacement for <Link> that respects all registered guards:

import { StayLink } from "next-stay";

<StayLink href="/other-page">Go somewhere</StayLink>

4. Use Guarded Router (optional)

Drop-in replacement for useRouter() that checks guards before navigating:

"use client";

import { useStayRouter } from "next-stay";

export function MyComponent() {
  const router = useStayRouter();

  const handleClick = () => {
    // Will ask for confirmation if any guards are active
    router.push("/dashboard");
  };

  return <button onClick={handleClick}>Go to Dashboard</button>;
}

Custom Confirmation Dialog

Instead of window.confirm(), use any async confirmation (modal, toast, etc.):

const [showModal, setShowModal] = useState(false);
const resolveRef = useRef<(value: boolean) => void>();

useStay({
  enabled: isDirty,
  confirm: () =>
    new Promise<boolean>((resolve) => {
      resolveRef.current = resolve;
      setShowModal(true);
    }),
});

// In your JSX:
{showModal && (
  <Dialog>
    <p>Unsaved changes will be lost.</p>
    <button onClick={() => { resolveRef.current?.(true); setShowModal(false); }}>
      Leave
    </button>
    <button onClick={() => { resolveRef.current?.(false); setShowModal(false); }}>
      Stay
    </button>
  </Dialog>
)}

Mantine Integration

If you use Mantine, next-stay provides a ready-made integration that uses Mantine's confirm modal instead of window.confirm().

npm install @mantine/core @mantine/modals

Make sure ModalsProvider is set up in your app (see Mantine docs), then:

"use client";

import { useState } from "react";
import { useStayModal } from "next-stay/mantine";

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

  useStayModal({
    enabled: isDirty,
    title: "Unsaved changes",
    message: "You have unsaved changes. Are you sure you want to leave?",
    confirmLabel: "Leave",
    cancelLabel: "Stay",
    confirmColor: "red",
  });

  return (
    <form>
      <input onChange={() => setIsDirty(true)} />
      <button type="submit">Save</button>
    </form>
  );
}

All options except enabled are optional and have sensible defaults. The modal is fully styled by your Mantine theme.

Usage Notes

  • Client components only. All next-stay exports are marked "use client" and rely on browser APIs (window, history, navigation). Use them inside client components; they cannot be imported from Server Components or Server Actions.
  • Multiple guards on one page are supported. Every active guard is asked sequentially - if any guard's confirm() returns false, navigation is blocked. This lets you have several independent forms on the same page, each with its own dirty state.
  • StayLink skips the guard for target="_blank" and download links because those don't navigate the current page.

API Reference

<StayProvider>

Wrap your app with this component. Sets up beforeunload and back/forward listeners. Mount once in your root layout.

useStay(options)

Registers a navigation guard. While enabled is true, any client-side navigation (StayLink, useStayRouter, browser back/forward, tab close) will trigger the confirm callback.

Option Type Description
enabled boolean Whether this guard is active
confirm () => boolean | Promise<boolean> Confirmation callback. Defaults to window.confirm()

Returns void. The guard is automatically unregistered when the component unmounts.

useStayRouter()

Same API as Next.js useRouter(), but push, replace, back, and forward check all registered guards first. refresh and prefetch are passed through unguarded.

useStayBlocked()

Returns true if any guard is currently active. Subscribes to the global guard registry, so the component re-renders whenever a guard is enabled or disabled. Use this when you want to render UI based on whether navigation is blocked, without registering a guard yourself.

"use client";

import { useStayBlocked } from "next-stay";

export function UnsavedBanner() {
  const blocked = useStayBlocked();
  if (!blocked) return null;
  return <div>You have unsaved changes</div>;
}

checkStayGuards()

async () => Promise<boolean>

Manually run all currently active guards and resolve with true if every guard returned true (or there are no active guards), false otherwise. Use this before triggering a navigation that does not go through useStayRouter or <StayLink> - for example window.location.assign, window.open, or a third-party SDK call.

"use client";

import { checkStayGuards } from "next-stay";

async function logout() {
  if (await checkStayGuards()) {
    window.location.assign("/auth/logout");
  }
}

<StayLink>

Same props as Next.js <Link>, plus:

Prop Type Description
guardConfirm () => boolean | Promise<boolean> Override confirmation for this link only

If guardConfirm is provided, it is used instead of the registered guards' own confirm callbacks. If target="_blank" or download is set, the guard is bypassed entirely.

useStayModal(options) - Mantine

Option Type Default Description
enabled boolean - Whether guard is active
title string "Unsaved changes" Modal title
message string Generic unsaved message Modal body text
confirmLabel string "Leave" Confirm button text
cancelLabel string "Stay" Cancel button text
confirmColor string "red" Confirm button color

Returns void.

Browser Compatibility

Feature Chrome / Edge Firefox Safari
<StayLink> guard
useStayRouter() guard
Browser back/forward guard ✅ Navigation API ✅ popstate fallback ✅ popstate fallback
Tab close / reload guard

Browser back/forward uses the Navigation API in browsers that support it (Chrome 102+, Edge 102+) for clean interception. In Firefox and Safari, a popstate-based fallback is used - this works reliably but may cause a brief URL flicker in the address bar when navigation is blocked.

Requirements

  • Next.js >= 15.3 (uses the official onNavigate prop on <Link>, introduced in 15.3)
  • React >= 19
  • Mantine >= 7 (optional, for next-stay/mantine)

The CI test matrix runs against Next.js 16. Next.js 15.3 and later work because the library only depends on the onNavigate prop, but those versions are not part of the test matrix.

Links

Feedback & Contributing

Bug reports, feature requests, and feedback are very welcome. The best place is the issue tracker - no template, just describe what you ran into or what you would like to see. Pull requests are also welcome; for larger changes please open an issue first so we can align on the approach.

License

Apache 2.0 - see LICENSE.

About

Robust navigation guard for Next.js 15.3+ App Router. Protects against unsaved changes across all navigation types.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors