🤔

Next.jsの離脱防止は結局どうするのがいいのか

に公開

Next.jsの離脱防止は難しい。

こちらはFUN Part2 Advent Calendar 2025 4日目の記事です。

Next.jsの事情について知らない方はここを読んでください

Next.jsのページ遷移は特殊です。
通常のHTMLで作ったサイトであれば、サイト内リンクを踏むとブラウザが新しいページのHTMLを取得し、それを新しいページとして表示します。
この動きを、Next.jsの用語では「ハードナビゲーション」と呼びます。

一方、Next.jsで作ったサイトで、サイト内リンクを踏んでもHTMLはダウンロードされず、ブラウザは遷移したことに気づきません。
代わりにNext.jsはページ内リンクを踏んだ際、踏んだ先のページの内容をJavaScriptで自力で取得し、画面に反映します。
この動きを、Next.jsの用語では「ソフトナビゲーション」と呼びます。

問題

ソフトナビゲーションではこれが表示できない。
このサイトを再読み込みしますか?

解決案

1. Next.jsのAPIなどを上書きして無理やり止める

こちらで実践されています。
とにかく全ての遷移パターンに対して対策を入れています。
https://speakerdeck.com/ypresto/hack-to-prevent-page-navigation-in-next-js
素晴らしい実践ですが、公式のデモでもOKを押したときの挙動が怪しいです。
本来であれば、こちらにPRを送るのがあるべき姿かもしれませんが、今回は時間がないため見送りました。(本当に申し訳ないです。時間を見つけて対応したい。)

ともかく、ハッキーな手法は、ブラウザの挙動が変わったりしたときに影響が出かねないので、できれば避けたいです。

2. 離脱防止が必要な場面でだけ、あえてハードナビゲーションする

この方法であれば、ブラウザ標準の離脱防止が使えます。加えて実装コストも低いです。

ただし、この方法にも弱点はあり、グローバルなcontextが重要なWebサイトでは、別途考慮する必要が出てしまいます。
ハードナビゲーションでもソフトナビゲーションでもコンテンツに影響がない場合であれば、多少のパフォーマンス低下に見合う、実装コストの低さがあります。

今回の記事ではこちらの方法をご紹介します。

別解: そもそも離脱防止が必要ないように作る

まさにこのZennがそうなのですが、自動保存機能などをつけることで、離脱してしまっても問題がない設計にする、という方法があります。
特に、スマートフォンで利用されることも考えると、ブラウザ標準の離脱防止でもタスクキルには抗えないため、この方法が有力になってきます。

ただこの機能だけだと、通常のWebサイトと挙動が異なるためユーザーが混乱して、うまく下書きにアクセスできない可能性があります。

例えばZennでは、下書きの時点で記事IDが振られており、復元するにはそのIDの編集ページに戻ってくる必要があります。
そこをZennでは、ハードナビゲーションしようとした時にはブラウザ標準の離脱防止が、ページ内のソフトナビゲーションな戻るボタンを押したときにはカスタムのconfirmダイアログが表示されるようにされており、合わせ技で対処されています。
下書き機能は、タスクキルや、PCが落ちてしまったとき用の最終手段としているようです。

この方法を取るには、専用のUIが必要なため、デザイナーとのコミュニケーションが重要ですね。

実際に、離脱防止が必要な場面でだけ、あえてハードナビゲーションする

離脱防止が必要なページへのリンクを置き換えて

- <Link href="/contact">お問い合わせ</Link>
+ <a href="/contact">お問い合わせ</a>

離脱防止が必要なページにはこのような記述をしておくことで、ブラウザ標準のダイアログが出るようになります。

useEffect(() => {
  if (!isDirty) {
    return;
  }
  const handleBeforeUnload = (event: BeforeUnloadEvent) => {
    event.preventDefault();
  };
  window.addEventListener("beforeunload", handleBeforeUnload);
  return () => {
    window.removeEventListener("beforeunload", handleBeforeUnload);
  };
}, [isDirty]);

離脱防止が必要なページ内のリンクの置き換えもお忘れなく。

共通コンポーネントがあるなど、一括で置き換えたい場合

サイト全体を囲うcontextと、サイト全体で使うwrapされたLinkタグの例を置いておきます。
この例では、離脱防止が必要ないlayout内を移動するために、脱出ハッチを用意しています。

RouterProvider.tsx
import {
  AppRouterContext,
  type AppRouterInstance,
} from "next/dist/shared/lib/app-router-context.shared-runtime";
import { useRouter } from "next/navigation";
import {
  createContext,
  type Dispatch,
  type SetStateAction,
  useContext,
  useEffect,
  useMemo,
  useState,
} from "react";

const RouterProviderContext = createContext<
  | {
      requestHardNavigationState: [number, Dispatch<SetStateAction<number>>];
      originalRouter: AppRouterInstance;
    }
  | undefined
>(undefined);

export const useRequestHardNavigation = () => {
  const context = useContext(RouterProviderContext);
  if (!context) {
    throw new Error(
      "useRequestHardNavigation must be used within a RouterProvider",
    );
  }

  const { originalRouter, requestHardNavigationState } = context;

  const [, setRequestHardNavigationState] = requestHardNavigationState;

  useEffect(() => {
    setRequestHardNavigationState((prev) => prev + 1);
    return () => {
      setRequestHardNavigationState((prev) => prev - 1);
    };
  }, [setRequestHardNavigationState]);

  return originalRouter;
};

const calculateIsRequestHardNavigation = (
  requestHardNavigationState: number,
) => {
  return requestHardNavigationState > 0;
};

export const useIsRequestHardNavigation = () => {
  const context = useContext(RouterProviderContext);
  if (!context) {
    throw new Error(
      "useIsRequestHardNavigation must be used within a RouterProvider",
    );
  }

  const [requestHardNavigationState] = context.requestHardNavigationState;

  return calculateIsRequestHardNavigation(requestHardNavigationState);
};

export const RouterProvider = ({ children }: { children: React.ReactNode }) => {
  const router = useRouter();
  const [requestHardNavigationState, setRequestHardNavigationState] =
    useState(0);

  const isRequestHardNavigation = calculateIsRequestHardNavigation(
    requestHardNavigationState,
  );

  const interceptedRouter = useMemo((): AppRouterInstance | null => {
    if (!router) {
      return null;
    }
    return {
      ...router,
      back: () => {
        if (isRequestHardNavigation) {
          history.back();
        } else {
          router.back();
        }
      },
      forward: () => {
        if (isRequestHardNavigation) {
          history.forward();
        } else {
          router.forward();
        }
      },
      push: (href, ...args) => {
        if (isRequestHardNavigation) {
          location.href = href;
        } else {
          router.push(href, ...args);
        }
      },
      replace: (href, ...args) => {
        if (isRequestHardNavigation) {
          location.replace(href);
        } else {
          router.replace(href, ...args);
        }
      },
      refresh: (...args) => {
        if (isRequestHardNavigation) {
          location.reload();
        } else {
          router.refresh(...args);
        }
      },
    };
  }, [router, isRequestHardNavigation]);

  return (
    <RouterProviderContext.Provider
      value={{
        requestHardNavigationState: [
          requestHardNavigationState,
          setRequestHardNavigationState,
        ],
        originalRouter: router,
      }}
    >
      <AppRouterContext.Provider value={interceptedRouter}>
          {children}
      </AppRouterContext.Provider>
    </RouterProviderContext.Provider>
  );
};
Link.tsx
import NextLink, { type LinkProps } from "next/link";
import type { ComponentProps } from "react";
import { useIsRequestHardNavigation } from "../RouterProvider";

type Props = LinkProps &
  ComponentProps<"a"> & {
    forceSoftNavigation?: boolean;
  };

export const Link = ({ forceSoftNavigation, ...props }: Props) => {
  const isRequestHardNavigation = useIsRequestHardNavigation();
  if (!forceSoftNavigation && isRequestHardNavigation) {
    return <a {...props} />;
  }
  return <NextLink {...props} />;
};

Discussion