npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

next-stay

v1.0.2

Published

Robust navigation guard for Next.js 16+ App Router - protects against unsaved changes on client navigation, back/forward, and tab close

Readme

Quadux IT Logo

next-stay

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.