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
Maintainers
Readme
next-stay
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
onNavigateprop 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-guardis another great library that solves the same problem. Check it out if you're looking for a more established option.
Installation
npm install next-stayQuick 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/modalsMake 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-stayexports 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()returnsfalse, navigation is blocked. This lets you have several independent forms on the same page, each with its own dirty state. StayLinkskips the guard fortarget="_blank"anddownloadlinks 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
onNavigateprop 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
onNavigateprop, 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.

