Unsaved changes in the Next.js App Router
The admin on this site has 4 forms (posts, projects, guides and guide topics), and none of them asked before letting me leave: a link in the nav, Logout or closing the tab, and whatever I’d typed was just gone.
I wouldn’t have noticed, since I’ve barely used the admin so far. Claude caught it, in a code review in September (more on how it helps with tests in iOS testing with Claude Code).
The App Router doesn’t have anything like React Router’s useBlocker (a page-level way to hold a navigation until the user confirms), so I wrote a small hook. It asks when the tab is closed or reloaded, and when a link inside the app is clicked. But it doesn’t ask when Back takes you to another page of the app; the App Router gives a page no way to cancel a history navigation.
Back from a form with unsaved changes just leaves. No prompt, and the edits are gone.
The closest thing Next has is onNavigate, a <Link> prop since 15.3. It runs when that link starts a client-side navigation, and calling preventDefault() cancels it; the docs even use it for this exact case. It’s per link, though, so pretty much every link has to be a wrapper that checks some shared “is anything unsaved?” state, the nav in the layout included, and it never sees a router.push, a closed tab or Back. I didn’t even know it existed when I wrote the guard.
Here’s the hook, trimmed a bit (the real one also tracks the form in a small registry, for Logout):
"use client"
import { useEffect } from "react"
const MESSAGE = "You have unsaved changes. Leave and discard them?"
export function useUnsavedChangesGuard(isDirty: boolean): void {
useEffect(() => {
if (!isDirty) {
return
}
function handleBeforeUnload(event: BeforeUnloadEvent) {
event.preventDefault() // ❶
event.returnValue = ""
}
function handleClickCapture(event: MouseEvent) {
if (!isLeavingInApp(event) || window.confirm(MESSAGE)) { // ❷
return
}
event.preventDefault() // ❸
event.stopPropagation()
}
window.addEventListener("beforeunload", handleBeforeUnload)
document.addEventListener("click", handleClickCapture, true) // ❹
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload)
document.removeEventListener("click", handleClickCapture, true)
}
}, [isDirty])
}While the form is dirty, we keep 2 listeners on the page, and take them off once it’s clean again, or unmounts. beforeunload covers closing or reloading the tab, and leaving for another site. When we call preventDefault() on it (❶), the browser shows its own dialog, whose text we can’t set; returnValue is there for older Chromium and Safari, which still read that instead.
Now, the clicks. Why document, and why the true at the end (❹)?
That true puts our listener in the capture phase: the first half of a click’s path, from window down to the element that was clicked, before it bubbles back up. React doesn’t put listeners on each element; since React 17, it listens once, on the root it renders into, and <Link>’s onClick runs from there. document sits above that root, so we get the click before React does.
If the admin picks Cancel, we call stopPropagation(), so React never even hears about the click and <Link> never calls router.push. We also call preventDefault() (❸), so the browser doesn’t follow the href on its own.
A regular listener on document would be too late: by the time the click bubbles back up there, <Link> has already started the navigation.
The dialog is a plain confirm (❷) — it blocks, so we have the answer before the listener returns. A custom modal is a bit more work: it has to cancel every click, then call router.push itself if the answer is to leave.
We only ask when a click actually leaves the page, which is what isLeavingInApp decides:
function isLeavingInApp(event: MouseEvent): boolean {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return false
}
const anchor =
event.target instanceof Element ? event.target.closest("a[href]") : null
if (!(anchor instanceof HTMLAnchorElement)) {
return false
}
if (
(anchor.target !== "" && anchor.target !== "_self") ||
anchor.hasAttribute("download")
) {
return false
}
const destination = new URL(anchor.href, window.location.href)
if (destination.origin !== window.location.origin) {
return false
}
return (
destination.pathname !== window.location.pathname ||
destination.search !== window.location.search
)
}We let through anything that doesn’t leave the page in this tab:
- a Cmd-click or a middle click, which opens a new tab and keeps the form
- a link with a
target, same thing - a download, which doesn’t navigate
- a link to another site, a full unload that
beforeunloadalready covers - a
#sectionlink, which stays on the page
We do ask on a different query for the same path, since Next renders a new page for it. Same for a click on a <span> inside a link, which is what closest is for.
The test stands in for <Link> with a plain React onClick on the anchor, and checks that it never runs when confirm returns false.
Logout is a button, not a link, so the click listener never sees it. It also lives in the layout, which shares no tree with the form, so it asks through a module-level Set of dirty forms instead. The hook adds its form when it turns dirty and removes it on cleanup; Logout only calls confirm if the set isn’t empty. If your app has other buttons that navigate with router.push, they might need the same, since the listener only looks at links.
Saves in flight
Pressing Save doesn’t make the form clean, and the guard stays on while the request is out, on purpose. Leaving at that point unmounts the form, which cancels the request (the same abort-on-unmount as in the admin write contract), and I’d be left not knowing whether the save landed.
A failed save keeps the guard on too, since the edits still only live in the form.
So when does it stand down? Once the save succeeds. Each form passes it a single boolean; here’s the post form’s:
useUnsavedChangesGuard((isDirty || isUploading) && !hasSucceeded)An upload in flight counts as unsaved, because the file’s URL only reaches the form’s state when the upload lands. hasSucceeded comes from the hook that does the saving, and it turns true right before the form heads back to the list; the router.push that takes it there isn’t a click, so the guard was never in its way.
The first version didn’t have hasSucceeded. I knew the guard would stay on until the list rendered, a server round trip later, and I took a pointless prompt in that gap over losing a save. That lasted a day.
isDirty never resets after a save, so a click in that gap warned about edits that were already stored. Worse, when the navigation fell back to a full page load, the browser’s own dialog showed up, and choosing to stay left Save disabled for good (a successful save keeps the button off, so the form can’t post twice).
The Back button
Inside an App Router app, Back doesn’t unload anything. Next adds each page to the history itself, with history.pushState, so going back is just the browser moving to the previous entry and firing popstate, and Next renders the previous page when it hears it.
By the time we hear about it, the URL has already changed. popstate can’t be canceled, beforeunload doesn’t fire since the document stays, and onNavigate belongs to a link nobody clicked.
The Pages Router at least had router.beforePopState, which let a page tell Next to ignore a popstate; the App Router doesn’t have one.
Back out of the app is different, it’s a full unload (to another site, or to wherever the tab was before the admin loaded), and beforeunload asks. It’s Back within the admin, most likely from a form to the list it came from, that goes through without a prompt.
This has been missing for quite a while: an issue from 2017 already noted that the workarounds of the time caught link clicks and missed browser navigation.
The one you’ll probably find in the App Router discussion, and in the posts around it, goes after popstate. The idea is to push an extra history entry while the form is dirty, so Back only lands on the copy, then ask in a popstate listener and push it again if the answer is to stay. It works against Next more than with it, though: Next is handling that same popstate, the history keeps a duplicate entry, and once you do want to leave, it takes Back twice.
The other routes leave Next’s history alone. The Navigation API’s navigate event can cancel a Back within the same document, in Chromium at least, but only once per user interaction, so a second press always goes through. MDN still lists canceling traversals as not implemented, though.
TanStack Router’s docs skip blocking altogether: there’s no reliable cross-browser way to stop Back, so they suggest keeping the draft in sessionStorage and restoring it when the user comes back.
Next 16 has something pretty close to that built in. With cacheComponents on, it hides the page you leave with React’s <Activity> instead of unmounting it, for a few recent routes, and form drafts are on the docs’ list of state that survives, so Back would still leave without asking, but Forward should bring the form back with the edits in it. This site doesn’t run with cacheComponents, so I can’t say how it gets along with the guard.
For now, I’ve left Back alone, and I’ll look at it again if Next ever adds a way to block navigation.
If you’ve got Back asking on an App Router app, without fighting the router for the history, show me how: @roland.leth.ro or @rolandleth.