本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

要点

調査した限りでは次のような事象でした。

  • Next.js 16.2.x + next-intl 4.9.x の構成で、開発モードでは問題なく動く検索ページが、本番ビルド(next build)後だけ対話機能(表示数切替・ファセット・キーワード送信・ページング)を失う
  • ハンドラ・URL生成・router.push(target) の呼び出しまでは正常に発火している。router オブジェクトも truthy で、target URL も期待通り
  • ただし router.push 後にブラウザ URL が変わらず、useSearchParams も更新されないため、結果取得の useEffect が再実行されず画面が反映されない
  • next-intl 経由の useRouter でも、next/navigation の native useRouter でも同じ症状を観測。dev / Turbopack では再現しない
  • 同一函数 updateUrl に集約されているので、router.push(target)history.pushState({}, '', target); dispatchEvent(new PopStateEvent('popstate')) に差し替えると useSearchParamspopstate に反応して useEffect が連鎖し、対話が復活する
  • 関連: vercel/next.js#51782

状況

ある検索系 Web アプリで、検索バックエンド(Elasticsearch)のデータを更新し、それに合わせてフロントの「お知らせ」ページを 1 件追加するだけの作業を行っていました。フロントは Next.js 16.2.x、ルーティング/国際化に next-intl 4.9.x を使う構成です。

お知らせの追加は content/{ja,en}/news/<date>.md を追加して再ビルドするだけで完結する想定でしたが、フロントを再ビルドして配信した時点で、利用者から「ファセットも表示数切替も効かない」と連絡を受けました。

サイト自体は描画されており、初回 24 件の検索結果も表示されます。一方で、

  • <select id="size">96 に変えても結果は 24 件のまま
  • 検索ボックスにキーワードを入れて Enter しても URL が変わらない
  • ファセット項目をクリックしても絞り込まれない
  • ページング操作も無反応

という状態でした。ブラウザの DevTools コンソールにエラー出力はなく、ネットワークタブにも対話操作後の API リクエストが出ません。

切り分け

検索ページのハンドラは下記のような典型的なパターンになっていました。

const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()

const updateUrl = useCallback((updates) => {
  const params = new URLSearchParams(searchParams.toString())
  // updates を params に反映する処理(省略)
  router.push(`${pathname}?${params.toString()}`)
}, [searchParams, router, pathname])

const handleSizeChange = useCallback((newSize) => {
  setSize(newSize)
  updateUrl({ size: newSize, page: 1 })
}, [setSize, updateUrl])

updateUrl から router.push までの間に console.log を仕込み、Playwright のヘッドレスから <select id="size"> を 96 に変えて出力を確認しました。

[DBG handleSizeChange] newSize= 96
[DBG updateUrl]        updates= {size: 96, page: 1}
[DBG router.push]      target= /search?fc-source=...&size=96&page=1   router?= true

ハンドラは発火しており、updateUrl も呼ばれ、router も truthy で、target URL も正しいです。しかしこの直後に page.url() を取ると URL は不変で、api への新しいリクエストも観測されません。

ここから順に仮説を潰しました。

#仮説結果
1next-intl の wrapped useRouter 固有の問題該当しないようでした。next/navigation の native useRouter に差し替えても同じ症状
2middleware を src/ ではなくプロジェクト root に置くと直る(GH#51782 で言及)src/ ベースの構成では root に動かすとビルドは通るがルーティングが 404 になり、本構成では適用できず
3特定パッチバージョンに依存16.2.1 → 16.2.6 への bump および npm audit fix を経ても症状不変
4開発モード(Turbopack)では再現するかnpm run dev では一切再現せず、すべての操作が正常
5next build --turbopack で本番ビルドすると改善するか同じく no-op になる
6history.pushState({}, '', target); dispatchEvent(new PopStateEvent('popstate')) で代替するとどうかURL が変わり、useSearchParams も更新され、useEffect の refetch が走る

開発モードでは動作する一方で、本番ビルドのみで起きるという挙動から、ビルドプロセス側で navigation が削られている、あるいは特定のコードパスが本番のコード分割や最適化と相性が悪い、といった可能性が考えられます。現時点ではフレームワーク側の動きを正確に追えてはいないため、断定は避けます。

回避策

navigation を実行している箇所が updateUrl 一函数に集約されているため、ここを書き換えるだけで handleSizeChange / handleFacetSelect / handleSearch / handlePageChange をまとめて回復できます。

// Workaround: Next.js 16 では本番ビルド時に `useRouter().push()` が無音で no-op になり、
// 検索ページの対話操作(表示数切替・ファセット選択・ページング・キーワード送信)が
// 機能しない症状を観測している(dev/Turbopack では再現しない)。
//   - イベントハンドラ・updateUrl・router.push の呼び出しまでは正常(値も正しい)
//   - 但し router.push 後にブラウザURL が変わらず useSearchParams も更新されないため
//     useEffect の refetch がトリガされず、結果が変わらない
//   - 参考: https://github.com/vercel/next.js/discussions/51782 ほか
//   - next-intl 経由の useRouter でも native の useRouter でも同じ症状
// 回避: history.pushState で URL を変更し popstate を発火させ useSearchParams を
// 更新する。Next.js 側で修正されたら素の router.push(target) に戻す想定。
const updateUrl = useCallback(
  (updates: Record<string, string | string[] | number | null>) => {
    const params = new URLSearchParams(searchParams.toString())

    Object.entries(updates).forEach(([key, value]) => {
      params.delete(key)
      if (value !== null && value !== undefined && value !== '') {
        if (Array.isArray(value)) {
          value.forEach((v) => params.append(key, v))
        } else {
          params.set(key, String(value))
        }
      }
    })

    const target = `${pathname}?${params.toString()}`
    if (typeof window !== 'undefined') {
      window.history.pushState({}, '', target)
      window.dispatchEvent(new PopStateEvent('popstate'))
    } else {
      router.push(target)
    }
  },
  [searchParams, router, pathname]
)

実環境ではヘッドレスでも実ブラウザでも、size 切替・ファセット選択・ページングが期待通り動作するところまで確認しました。キーワードフォーム送信も同じ updateUrl 経由なので合わせて復旧します。

注意点

  • usePathnamenext/navigation (native) のものを使う方が無難です。next-intl の usePathname は locale prefix を含めない実装になっており、/en/search のような prefixed パスで pushState すると /en/ を取りこぼします。next/navigationusePathname は実 URL のパスをそのまま返すため、prefix を保てます
  • pushState は Next.js の RSC ナビゲーションを経由しません。router.prefetch 等の最適化は同じトランジション内では失われます。「同一ルートでクエリだけ変える」用途(検索ページのファセット操作など)であれば影響は小さいですが、別ルートへの遷移は素直に <Link> / router.push を使う方が無難です
  • これは根治ではなく回避策です。Next.js 側で修正が入ったら素の router.push(target) に戻せるよう、コメントで意図を残しています

関連報告

vercel/next.js#51782 には、Next.js 15.0.3 / 15.2.2 / 15.3.1 / 15.3.2 など複数バージョンで「dev では動くが本番ビルドだけ router.push がページ遷移しない」事象が報告されています。スレッド内で挙げられている対処は概ね次のとおりです。

  • middleware.ts をプロジェクト root に置く(src/ ベースでない構成向け)
  • router.refresh() を挟んでから router.replace() を使う
  • window.location.href でハードナビゲーションする(SPA を捨てる)

src/ ベースの Next.js 16 + next-intl の組み合わせでは、middleware (Next.js 16 では proxy.ts) の置き場所変更は適用できなかったため、pushState + popstate への置換が影響範囲の小さい選択肢になりました。

補足

切り分け中に立ち寄った観点をいくつかメモとして残しておきます。記事の主題からは外れますが、同種の事象に遭遇した方の参考までに。

  • 開発モードと本番ビルドで挙動が分かれた場合、まず next build && node .next/standalone/server.js をローカルで起動して再現を取るのが早い。HOSTNAME=localhostNEXT_PUBLIC_API_URL を実環境と揃えると挙動が一致しやすい
  • next build 後の .next/standalone/ には public/.next/static/ が自動でコピーされないため、手動で cp -R する必要がある。これは Next.js 公式ドキュメント にも明記されている挙動
  • Next.js 16 では従来の middleware.tsproxy.ts という名称に変更されている。src/ ベースの構成では src/proxy.ts を Next.js が認識する。GH#51782 で言及されている「root に置く」アドバイスは src/ ベースのプロジェクトでは適用できない
  • 同種の事象の有無を web 検索する場合、症状語(router.push no-op / router.push not working in production)で検索すると一次情報に辿り着きやすい。エラーメッセージが出ない無音失敗のため、エラー文字列で検索しても見つかりにくい

残課題

回避策は機能していますが、フレームワーク側の根本原因はまだ追えていません。useRouter().push がどのレイヤーで no-op になっているのか(AppRouterContext の dispatch が握り潰されているのか、startTransition がスタックしているのか、特定の Suspense / RSC 境界の影響なのか)を確認するには、Next.js のソース側に踏み込む必要があります。Next.js 側で同種の修正が入ったコミットを見つけられたら、また追記します。