DEV Community

Cover image for I built a tiny hook to solve the missing navigation guard in Next.js App Router
Gichan
Gichan

Posted on

I built a tiny hook to solve the missing navigation guard in Next.js App Router

The problem

Ever spent 10 minutes filling out a form, accidentally clicked a link, and lost everything?

While working on an admin dashboard, I kept running into this. Users would fill out a long form, accidentally hit the browser back button or click a wrong link — and all their work was gone.

The fix seemed simple: "just show a confirmation popup when they try to leave."

Turns out it's more complicated than that.

Three exit paths you need to cover

To properly guard a form, you need to handle all three ways a user can leave:

  1. Tab close / page refreshbeforeunload event
  2. SPA navigation (<Link> clicks, router.push()) → router-level intercept
  3. Browser back/forward buttonpopstate event

The first one is easy — just attach a beforeunload listener. The other two are where things get tricky.

Why existing solutions didn't work

React Hook Form's isDirty

formState.isDirty tells you whether the form is dirty. It doesn't block navigation. It's a state value, not a navigation guard.

Next.js Pages Router's router.events

In Pages Router, you could intercept SPA navigation like this:

router.events.on('routeChangeStart', handler)
Enter fullscreen mode Exit fullscreen mode

This worked great. Then App Router came along and removed it entirely. No official replacement. GitHub Issues are full of "how do I do this now?" questions with answers that basically say "copy-paste this 30-line useEffect into every project."

react-router-prompt

React Router only. Doesn't work with Next.js.

What I built

I got tired of copy-pasting the same workaround into every project, so I packaged it into a hook: use-form-guard.

npm install use-form-guard
Enter fullscreen mode Exit fullscreen mode
useFormGuard(isDirty) // that's it
Enter fullscreen mode Exit fullscreen mode

Works with any form library — just pass a boolean:

// React Hook Form
useFormGuard(formState.isDirty)

// Formik
useFormGuard(formik.dirty)

// TanStack Form
useFormGuard(form.state.isDirty)
Enter fullscreen mode Exit fullscreen mode

Custom dialog support too:

useFormGuard({
  isDirty,
  message: 'You have unsaved changes. Leave anyway?',
  onBlock: () => openMyModal(), // return Promise<boolean>
  enabled: !isPreviewMode,
})
Enter fullscreen mode Exit fullscreen mode

How it works under the hood

1. Tab close / refresh — beforeunload

window.addEventListener('beforeunload', (e) => {
  if (!isDirty) return
  e.preventDefault()
  e.returnValue = message
})
Enter fullscreen mode Exit fullscreen mode

Modern browsers ignore custom messages for security reasons — you'll always get the browser's native dialog. Nothing we can do about that.

2. SPA navigation — monkey-patching history.pushState

This is the interesting one. In Next.js App Router, both <Link> clicks and router.push() internally call window.history.pushState. So if we patch that, we catch everything.

const original = window.history.pushState
window.history.pushState = function (...args) {
  if (isDirty) {
    if (window.confirm(message)) original.apply(this, args)
    return
  }
  original.apply(this, args)
}
Enter fullscreen mode Exit fullscreen mode

No router.events needed.

3. Browser back/forward — popstate + history.go(1)

When the back button is pressed, popstate fires after the navigation already happened. The trick is to immediately reverse it with history.go(1), then show the confirmation dialog.

window.addEventListener('popstate', () => {
  if (!isDirty) return
  window.history.go(1) // reverse immediately
  setTimeout(() => {
    if (window.confirm(message)) window.history.go(-1) // allow if confirmed
  }, 100)
})
Enter fullscreen mode Exit fullscreen mode

The setTimeout is needed because history.go(1) is asynchronous — we wait for the history to restore before showing the dialog.

4. Avoiding stale closures — useRef pattern

To keep useEffect dependencies as [] while always reading the latest isDirty value:

const shouldBlockRef = useRef(shouldBlock)
shouldBlockRef.current = shouldBlock // sync on every render
Enter fullscreen mode Exit fullscreen mode

Event listeners read shouldBlockRef.current instead of the captured value.

Bugs I hit along the way

beforeunload tests always failing in jsdom

new Event('beforeunload') defaults to cancelable: false, so e.preventDefault() does nothing and defaultPrevented stays false. Fix:

new Event('beforeunload', { cancelable: true })
Enter fullscreen mode Exit fullscreen mode

TypeScript 6.0 DTS build error

tsup sets baseUrl internally during .d.ts generation, which is deprecated in TypeScript 6.0. Fix:

{
  "compilerOptions": {
    "ignoreDeprecations": "6.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

How it compares

Feature use-form-guard Manual beforeunload react-router-prompt
Tab close / refresh
SPA navigation ✅ (RR only)
Back/forward button ✅ (RR only)
Next.js App Router
Custom dialog
Zero dependency

Known limitations

  • onBlock (async custom dialog) in App Router may have edge cases due to React concurrent rendering starting before pushState is intercepted. Works reliably with the window.confirm path.
  • Tab close/refresh always shows the browser's native dialog — no way around it.

Wrapping up

What started as a small annoyance turned into a surprisingly deep rabbit hole — jsdom quirks, TypeScript build issues, npm auth problems. But the result is a ~0.8KB hook that replaces a 30-line copy-paste in every project.

npm install use-form-guard
Enter fullscreen mode Exit fullscreen mode

If you try it out and find any bugs or have ideas for improvement, feel free to open an issue!

Top comments (0)