---
title: "Unsaved changes in the Next.js App Router"
slug: unsaved-changes-in-the-next-js-app-router
section: tech
date: 2026-11-03T16:45:00.000Z
canonical: https://roland.leth.ro/blog/tech/unsaved-changes-in-the-next-js-app-router
description: "Warning about unsaved changes in the Next.js App Router: closing the tab, in-app links, a save in flight, and why the browser’s Back button can’t be covered."
---

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][11]).

The App Router doesn’t have anything like React Router’s [`useBlocker`][1] (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][2] 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):

```typescript
"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:

```typescript
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 `beforeunload` already covers
- a `#section` link, 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][3]), 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:

```typescript
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][4], `beforeunload` doesn’t fire since the document stays, and `onNavigate` belongs to a link nobody clicked.

The Pages Router at least had [`router.beforePopState`][5], 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][6] 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][7], 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][8] still lists canceling traversals as not implemented, though.

[TanStack Router’s docs][9] 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][10] 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][].

[1]: https://reactrouter.com/hooks/use-blocker "useBlocker"
[2]: https://nextjs.org/docs/app/api-reference/components/link "Link Component"
[3]: /blog/tech/the-admin-write-contract "The admin write contract"
[4]: https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event "Window: popstate event"
[5]: https://nextjs.org/docs/pages/api-reference/functions/use-router "useRouter"
[6]: https://github.com/vercel/next.js/issues/2476
[7]: https://github.com/vercel/next.js/discussions/47020
[8]: https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API "Navigation API"
[9]: https://tanstack.com/router/latest/docs/framework/react/guide/navigation-blocking "Navigation Blocking"
[10]: https://nextjs.org/docs/app/guides/preserving-ui-state
[11]: /blog/tech/ios-testing-with-claude-code "iOS testing with Claude Code"
[@roland.leth.ro]: https://bsky.app/profile/roland.leth.ro "@roland.leth.ro on Bluesky"
[@rolandleth]: https://x.com/rolandleth "@rolandleth on X"
