The problem

At time of writing, many of the router.events — such as routeChangeStart()— that exist in the pages router of NextJS, are not currently available with the new app router.

There are many discussion threads on GitHub covering this issue, but at this time, the official docs don't provide a clear way of preventing page navigation with the new router.

The solution

The proposal here is to facilitate interrupting navigation events in the NextJS app router, whenever the user has unsaved changes on the page, in order to show them a modal warning them they're about to lose those changes; the modal will provide two buttons:

  • one to dismiss the modal and allow the user to return to the page
  • a second to allow them to proceed away from the page.

The solution I came up with was to use React Context to create an <UnsavedChangesProvider /> that wraps the entire site in the root layout.tsx file.

This, in combination with a custom hook around the beforeunload event, allowed me to implement the required functionality.

The code

Implementing this solution requires us to complete the following four tasks:

  1. Create an UnsavedChangesProvider that wraps our entire site, and stores whether or not there are unsaved changes in state
  2. Create a hook that facilitates the setting and un-setting of unsaved changes state within pages/components
  3. Create a HOC wrapper around the NextJS Link component, that taps-into the state stored in our UnsavedChangesProvider, to prevent navigation if there are unsaved changes
  4. Create a hook around the beforeunload event, to also intercept browser-based user navigation.

Create the provider

Our UnsavedChangesProvider will be made up of two components:

  1. The provider itself
  2. A warning modal which displays when the user attempts to navigate away from the page, if there are any unsaved changes

First of all, we'll create the types we need for the provider and modal:

/* UnsavedChanges.types.ts */

export interface IUnsavedChangesModalContent {
  message?: string;
  dismissButtonLabel?: string;
  proceedLinkLabel?: string;
  proceedLinkHref?: string;
}

export interface IUnsavedChangesContext {
  modalContent: IUnsavedChangesModalContent | undefined;
  setModalContent: Dispatch<
    SetStateAction<IUnsavedChangesModalContent | undefined>
  >;
  showModal: boolean;
  setShowModal: Dispatch<
    SetStateAction<boolean>
  >;
};

Next, we'll create the modal:

/* UnsavedChangesModal.tsx */

import React from 'react';
import NextLink from 'next/link';
import Modal from 'some-lib';

import { IUnsavedChangesContext } from './UnsavedChanges.types';

const UnsavedChangesModal: React.FC<IUnsavedChangesContext> = ({
  modalContent,
  setModalContent,
  showModal,
  setShowModal,
}) => (
  <Modal
    isOpen={showModal}
    onClose={() => {
      setShowModal(false);
    }}
  >
    <div className="modal-content">
      <p>{modalContent.message || "You have unsaved changes."}</p>

      <button
        onClick={() => {
          setShowModal(false);
        }}
      >
        {modalContent.dismissButtonLabel || "Back to page"}  
      </button>

      <Link
        href={modalContent.proceedLinkHref || '/'}
        onClick={() => {
          setShowModal(false);
          setModalContent(undefined);
        }}
      >
        {modalContent.proceedLinkLabel || "I don't want to save changes"}
      </Link>
    </div>
  </Modal>
);

export default UnsavedChangesModal;

Now we can create the provider itself:

 /* UnsavedChangesProvider.tsx */

import React, {
  createContext,
  useMemo,
  useState,
  type Dispatch,
  type PropsWithChildren,
  type SetStateAction
} from 'react';

import UnsavedChangesModal from './UnsavedChangesModal';

import {
  IUnsavedChangesModalContent,
  IUnsavedChangesContext,
} from './UnsavedChanges.types';
 
const UnsavedChangesContext = createContext<
  IUnsavedChangesContext | undefined
>(undefined);

const UnsavedChangesProvider: React.FC<
  PropsWithChildren
> = ({ children }) => {
  const [modalContent, setModalContent] = useState<
    IUnsavedChangesModalContent | undefined
  >(undefined);
  const [showModal, setShowModal] = useState<boolean>(false);

  const context = useMemo((): IUnsavedChangesContext => ({
    modalContent,
    setModalContent,
    showModal,
    setShowModal,
  }), [
    modalContent,
    setModalContent,
    showModal,
    setShowModal,
  ]);

  return (
    <UnsavedChangesContext.Provider value={context}>
      {children}
      <UnsavedChangesModal {...context} />
    </UnsavedChangesContext.Provider>
  );
};

export default UnsavedChangesProvider;

Finally, we'll wrap our site with the new UnsavedChangesProvider, by adding it to our root layout.tsx file:

/* @/app/layout.tsx */

import React, { type PropsWithChildren } from 'react';

import UnsavedChangesProvider from '@/path/to/UnsavedChangesProvider';

const RootLayout: React.FC<PropsWithChildren> = ({ children }) => (
  <html lang="en">
    <body>
      <UnsavedChangesProvider>
        {children}
      </UnsavedChangesProvider>
    </body>
  </html>
);

export default RootLayout;

Create the unsaved changes set/unset hook

Now that our site is wrapped with our new UnsavedChangesProvider, we have the ability to set unsaved changes throughout our app, within both pages and components.

In order to facilitate this, we're going to expand on our current UnsavedChangesProvider.tsx file by creating a hook, that we can import into our pages/components to set/unset unsaved changes.

First of all, we'll create the hook:

/* UnsavedChangesProvider.tsx */
import { useCallback, useContext } from 'react'

/* ... */

export default UnsavedChangesProvider;

export function useSetUnsavedChanges() {
  const context = useContext(UnsavedChangesContext);

  if (context === undefined) {
    throw new Error(
      'useSetUnsavedChanges must be called within <UnsavedChangesProvider />'
    );
  }

  const { setModalContent } = context;

  const setUnsavedChanges = useCallback((
    modalContent: Omit<IUnsavedChangesModalContent, 'proceedLinkHref'>
  ) => {
    setModalContent(config);
  }, [setModalContent]);

  const clearUnsavedChanges = useCallback(() => {
    setModalContent(undefined);
  }, [setModalContent]);

  return useMemo(() => ({
    setUnsavedChanges,
    clearUnsavedChanges,
  }), [setUnsavedChanges, clearUnsavedChanges]);
}

Now we can import this hook and use it, for example in a page of our site:

/* @/app/settings/page.tsx */

import React, { useCallback } from 'react';

import { useSetUnsavedChanges } from '@/path/to/UnsavedChangesProvider';

const SettingsPage: React.FC = () => {
  const {
    setUnsavedChanges,
    clearUnsavedChanges,
  } = useSetUnsavedChanges();

  const [username, setUsername] = useState<string>("");

  const handleUsernameChange = useCallback((e) => {
    setUsername(e.target.value);
    if (e.target.value !== "") {
      setUnsavedChanges();
    } else {
      clearUnsavedChanges();
    }
  }, [setUnsavedChanges, clearUnsavedChanges]);

  const handleSaveChanges = useCallback(async () => {
    // submit changes to api...
    clearUnsavedChanges();
  }, [clearUnsavedChanges]);

  return (
    <main className="settings-page">
      <input
        type="text"
        value={username}
        onChange={handleUsernameChange}
      />
      {username !== "" && (
        <button onClick={handleSaveChanges}>
          Save changes
        </button>
      )}
    </main>
  );
};

export default SettingsPage;

As per our types config, if we wanted, we could set a custom message and/or button labels when we call setUnsavedChanges :

setUnsavedChanges({
  message: "Your username changes have not been saved",
  dismissButtonLabel: "Back to settings",
  proceedLinkLabel: "Proceed without saving",
});

Create the HOC wrapper around NextJS Link

Okay so now that we have configured our UnsavedChangesProvider and also enabled the setting/un-setting of saved changes on a page in our app, we now need to have the unsaved changes modal display, if a user tries to navigate away from the page, with unsaved changes set on the provider.

The best way to do this is to create a lightweight wrapper around the NextJS Link component.

In order to do this, our custom Link component is going to need to:

  • know whether or not there are unsaved changes stored in our provider
  • be able to show the unsaved changes modal if there are any unsaved changes

To better facilitate this, let's go back to our UnsavedChangesProvider.tsx component, and add another hook at the bottom of the file, to provide this functionality for our custom Link component:

/* UnsavedChangesProvider.tsx */

export function useSetUnsavedChanges() {
  /* ... */
}

export function useUnsavedChanges() {
  const context = useContext(UnsavedChangesContext);

  if (context === undefined) {
    throw new Error(
      'useUnsavedChanges must be called within <UnsavedChangesProvider />'
    );
  }

  const {
    modalContent,
    setModalContent,
    setShowModal,
  } = context;

  const showUnsavedChangesModal = useCallback(
    (proceedLinkHref: string) => {
      setModalContent((currentContent) => ({
        ...currentContent,
        proceedLinkHref,
      }));
      setShowModal(true);
    },
    [setModalContent, setShowModal]
  );

  return useMemo(
    () => ({
      currentPageHasUnsavedChanges: modalContent !== undefined,
      showUnsavedChangesModal,
    }),
    [modalContent, showUnsavedChangesModal]
  );
}

Now let's quickly create the props type for our new Link component:

/* @/components/Link/Link.types.ts */

import type { HTMLAttributes, PropsWithChildren } from 'react';
import type { LinkProps as NextLinkProps } from 'next/link';

export type LinkProps = PropsWithChildren<
  NextLinkProps & HTMLAttributes<HTMLAnchorElement>
>;

Now we can create the custom Link component itself:

import React, { useCallback, type MouseEvent } from 'react';
import NextLink from 'next/link';
import { useRouter } from 'next/navigation';

import { useUnsavedChanges } from '@/path/to/UnsavedChangesProvider';

import type { LinkProps } from './Link.types';

const Link: React.FC<LinkProps> = ({
  href,
  onClick,
  children,
  ...nextLinkProps,
}) => {
  const nextRouter = useRouter();
  const {
    currentPageHasUnsavedChanges,
    showUnsavedChangesModal,
  } = useUnsavedPageChanges();

  const handleLinkClick = useCallback(
    (e: MouseEvent<HTMLAnchorElement>) => {
      e.preventDefault();

      if (onClick) {
        onClick(e);
      }
      
      if (currentPageHasUnsavedChanges) {
        showUnsavedChangesModal(href.toString());
      } else {
        nextRouter.push(href.toString());
      }
    },
    [
      currentPageHasUnsavedChanges,
      href,
      nextRouter,
      onClick,
      showUnsavedChangesModal
    ]
  );

  return (
    <NextLink
      href={href}
      onClick={handleLinkClick}
      {...nextLinkProps}
    >
      {children}
    </NextLink>
  );
};

export default Link;

As the interface for our custom Link component is the same as that imported from next/link, all we need to do now is run a find-and-replace in our codebase, to swap all existing imports of Link to point towards our new custom component:

- import Link from 'next/link'
+ import Link from '@/components'

It's also obviously important that, going forward, any/all new instance of links created in your app, import from the new custom Link component as well.

And that's it — supporting internal site navigation blocking with unsaved changes on the page is as simple as:

  • calling the setUnsavedChanges / clearUnsavedChanges methods wherever we need to, and
  • making sure we always import Link from our custom component

Prevent navigation triggered by the browser

Our current implementation covers blocking navigation for all internal links in our site, which is great, but what about preventing navigation on pages with unsaved changes, when the user tries to navigate away from the page using their browser (e.g. refreshing the page, or navigating to a new site)?

In order to add support for preventing browser navigation (when unsaved changes are set), we can make one final change to our UnsavedChangesProvider, by adding support for the beforeunload event to our custom useUnsavedChanges hook:

/* UnsavedChangesProvider.tsx */

export function useUnsavedChanges() {
  const context = useContext(UnsavedChangesContext);

  /* ... */

  useEffect(() => {
    const handleBeforeUnload = (e: BeforeUnloadEvent) => {
      if (modalContent !== undefined) {
        e.preventDefault();
        e.returnValue = '';
      }
    };

    window.addEventListener('beforeunload', handleBeforeUnload);
    return () => {
      window.removeEventListener('beforeunload', handleBeforeUnload);
    };
  }, [modalContent]);

  return useMemo(
    /* ... */
  );
}

That's it — now we also have support for blocking browser-based navigation away from the current page.

N.B: It should be noted that support for the beforeunload event varies from browser to browser; please see the MDN docs for details.

Thanks for reading. I wrote this article as a labour of love in my own time, to try and help out other frontend developers facing this issue. I welcome any/all constructive feedback on how this solution could be improved upon.

Thanks again for reading — hope this article helps you out.

If you're looking for a senior frontend developer, with years of experience both designing and developing applications and websites, please don't hesitate to get in touch.