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などを上書きして無理やり止める
こちらで実践されています。
とにかく全ての遷移パターンに対して対策を入れています。
素晴らしい実践ですが、公式のデモでも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内を移動するために、脱出ハッチを用意しています。
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>
);
};
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