Next.js 静的ビルドで全ページ最上部表示が崩れる scrollRestoration の罠
2026-04-28
Next.js + output: "export" 構成で運用していて、遷移後にページが途中から表示される事案が出た。記事ページだけでなくトップ・ログイン・カテゴリ一覧など全ページで散発的に再現する。
症状の整理:
- 記事 A をスクロールして最後まで読む
- ヘッダのロゴをクリックしてトップへ遷移 → トップページが途中から表示される(直前のスクロール位置を継承)
- ブラウザの戻るで記事に戻る → 正しく前回位置に復元される(これは便利)
ブラウザの history.scrollRestoration がデフォルトで auto になっていることが真因。Next.js のクライアント遷移は History API ベースなので、ブラウザは「同じセッション内の遷移」として扱い、前ページのスクロール位置をそのまま継承してしまう。
最小修正は 2 段構え:
Step 1: 描画前に scrollRestoration を manual に固定
layout.tsx の <head> に inline script を埋め込み、pre-paint で設定する。React のライフサイクルより前に走らせるのがポイント。
// app/layout.tsx
<head>
<script
dangerouslySetInnerHTML={{
__html: `if ('scrollRestoration' in history) history.scrollRestoration = 'manual';`,
}}
/>
</head>
Step 2: pathname 変更時に paint 直前で scrollTo(0,0)
// components/ScrollToTop.tsx
"use client";
import { useLayoutEffect, useEffect } from "react";
import { usePathname } from "next/navigation";
const useIsoLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;
export default function ScrollToTop() {
const pathname = usePathname();
useIsoLayoutEffect(() => {
if (typeof window === "undefined") return;
if (window.location.hash) return; // ハッシュリンクは尊重
window.scrollTo(0, 0);
}, [pathname]);
return null;
}
useEffect ではなく useLayoutEffect を使うのは、paint 後に scrollTo すると一瞬前ページの位置が見えるから。SSR で useLayoutEffect 警告が出るので、サーバー時は useEffect にフォールバックする isomorphic イディオムにする。
ハッシュ URL(/articles/foo/#section)は早期 return で除外。これを忘れると、目次クリックで section に飛んだ瞬間に最上部に弾き返されるという二次被害が起きる。
検証は手動で 6 経路を踏む:
- 記事 → トップ(ロゴクリック)→ 最上部?
- 記事 → カテゴリ → 最上部?
- 記事 → 別記事(関連リンク)→ 最上部?
- 記事 → ハッシュリンク内移動 → 該当 section?(最上部に飛ばないこと)
- ブラウザ戻る → 前回位置復元?(これは Next.js が別経路で対応)
- ブラウザリロード → 最上部?
trailingSlash 罠と並んで、Next.js 静的ビルドで「気づきにくい」UX 不具合の典型。コンポーネント 1 つと inline script 1 行で解決できるので、新規プロジェクトの初期テンプレに最初から入れておくべき。