Next.js App Router の落とし穴と解決策:実戦で踏んでしまった8つの地雷

はじめに
正直に言います。Next.js App Router を使い始めた当初、私は本当に酷い目に遭いました。
昨年末、会社のプロジェクトを Next.js 15 にアップグレードすることになり、「せっかくだから Pages Router から App Router に移行しよう」と意気込みました。公式ドキュメントには「パフォーマンス向上」「より良い開発体験」「革命的な Server Components アーキテクチャ」といった美辞麗句が並んでいましたから。
しかし現実は甘くありませんでした。初日から奇妙な問題のオンパレードです。
データが更新されない、ページが無限ロードに陥る、キャッシュ設定が効かない、Server Component と Client Component がごちゃ混ぜになる……。極めつけは、error.tsx ファイルに 'use client' を付け忘れただけで発生したバグの特定に3時間も費やしたことです。あの時の絶望感と言ったらありません。
その後、チーム内で「ハマりポイント」を共有したところ、80%は同じような問題で躓いていることが分かりました。そこで、私たちが踏み抜いた地雷とその回避策を整理して共有することにします。
この記事では理論はさておき、実戦経験のみを語ります。各問題について 「なぜハマるのか」「どうやって気づいたか」「どう解決するか」 を解説します。これを読めば、App Router の地雷原を安全に歩けるようになるはずです。
データ取得(Data Fetching)の落とし穴
落とし穴 1:クライアントでの重複データ取得
状況:
ユーザー情報を表示するページで、いつもの癖でこんなコードを書いていました。
// app/profile/page.tsx
'use client'
import { useEffect, useState } from 'react'
export default function ProfilePage() {
const [user, setUser] = useState(null)
useEffect(() => {
fetch('/api/user')
.then(res => res.json())
.then(data => setUser(data))
}, [])
if (!user) return <div>Loading...</div>
return <div>Hello, {user.name}</div>
}動きますが、これは典型的なアンチパターンです。「DB → API Route → クライアント」という無駄な通信が発生しています。
なぜハマるのか:
Pages Router 時代は useEffect でクライアントサイドフェッチするのが一般的でしたが、App Router の Server Components ならサーバー内で完結できます。API 層は不要です。
正解:
// app/profile/page.tsx(デフォルトは Server Component)
import { db } from '@/lib/db'
export default async function ProfilePage() {
// DB を直接叩く
const user = await db.user.findFirst()
return <div>Hello, {user.name}</div>
}これだけでパフォーマンスは劇的に向上します。
- API リクエスト削減
- DB への低遅延アクセス(サーバー内部通信)
- クライアント JS バンドルサイズの削減
教訓:
Server Component で取得できるデータは、クライアントに fetch させない。クライアント取得は、ユーザー操作(検索、フィルタリング)が必要な場合のみに限定する。
落とし穴 2:Route Handler のデフォルトキャッシュ
状況:
現在時刻を返す簡単な API を作ったのに、何度リロードしても時間が変わりません。
// app/api/time/route.ts
export async function GET() {
return Response.json({ time: new Date().toISOString() })
}10回リロードしても同じ時刻が返ってきます。「コードが反映されてない?」と疑うレベルです。
なぜハマるのか:
Next.js は GET リクエストの Route Handler をデフォルトでキャッシュします。設定ファイルのような静的データには最適ですが、動的データには致命的です。
解決策 1:動的明示
// app/api/time/route.ts
export const dynamic = 'force-dynamic' // 強制的に動的レンダリング
export async function GET() {
return Response.json({ time: new Date().toISOString() })
}解決策 2:Next.js 15 の新挙動(またはキャッシュ制御)
Next.js 15 では GET Route Handler のデフォルトが「キャッシュなし」に変更されました。以前のバージョンなら以下のように書きます。
// app/api/time/route.ts
export async function GET() {
return Response.json(
{ time: new Date().toISOString() },
{ headers: { 'Cache-Control': 'no-store' } }
)
}私の運用ルール:
- 静的データ:
export const revalidate = 3600と明記 - 動的データ:
export const dynamic = 'force-dynamic'と明記
デフォルト挙動に頼らず、意図をコードに書くようにしています。
落とし穴 3:データ変更後の再検証忘れ
状況:
Todo アプリでタスクを追加しても、リストに反映されません。
// app/todos/page.tsx
export default async function TodosPage() {
const todos = await db.todo.findMany()
return <TodoList todos={todos} />
}
// app/actions.ts
'use server'
export async function addTodo(text: string) {
await db.todo.create({ data: { text } })
// 再検証(Revalidate)を忘れている!
}なぜハマるのか:
App Router のキャッシュは強力なので、明示的に「ここのデータ古くなったよ」と伝えない限り、古いキャッシュを表示し続けます。
正解:
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function addTodo(text: string) {
await db.todo.create({ data: { text } })
revalidatePath('/todos') // /todos パスのキャッシュを破棄
}応用テクニック:
複数のページ(トップページ、アーカイブ一覧など)で使われているデータなら、タグベースの再検証が便利です。
// データ取得時
const todos = await fetch(..., { next: { tags: ['todos'] } })
// アクション時
revalidateTag('todos') // 'todos' タグがついた全キャッシュを破棄データ更新の三原則:書き込み →
revalidate→ リダイレクト(必要な場合)
Server Components と Client Components の混乱
落とし穴 4:Server Component で Context を使おうとする
状況:
テーマ切り替え機能を実装しようとして、layout.tsx に Provider を書いたらエラーになりました。Error: You're importing a component that needs createContext. This only works in a Client Component.
なぜハマるのか:
Server Components はサーバーでレンダリングされるため、React の状態管理(Context)が使えません。
正解:
Provider だけを Client Component として切り出します。
// app/providers.tsx
'use client' // 重要:ここだけ Client Component にする
import { createContext, useState } from 'react'
export const ThemeContext = createContext('light')
export function Providers({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState('light')
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}layout.tsx は Server Component のまま、この Providers をインポートして使います。
失敗談:
私は最初、手っ取り早く解決しようとして layout.tsx に 'use client' を付けてしまいました。その瞬間、アプリ全体がクライアントレンダリングになり、SEO やパフォーマンスの利点が消滅しました。
Provider だけを切り出す。Layout は Server Component のままにする。これが鉄則です。
落とし穴 5:Client Component の SSR 誤解
状況:localStorage を使う组件を書いたら、デプロイ後に localStorage is not defined エラーで落ちました。
'use client'
export default function UserInfo() {
// エラー発生!
const user = JSON.parse(localStorage.getItem('user') || '{}')
return <div>{user.name}</div>
}なぜハマるのか:'use client' は「クライアントだけで実行される」という意味ではありません。「クライアントバンドルに含まれる」という意味であり、初回はサーバーでプレレンダリング(SSR)されます。サーバーには window や localStorage はありません。
正解:
ブラウザ API は必ず useEffect の中か、環境チェック後に使います。
'use client'
import { useEffect, useState } from 'react'
export default function UserInfo() {
const [user, setUser] = useState(null)
useEffect(() => {
// useEffect はブラウザでのみ実行される
const userData = JSON.parse(localStorage.getItem('user') || '{}')
setUser(userData)
}, [])
if (!user) return null
return <div>{user.name}</div>
}教訓:Client Component もサーバーで一度実行される。
windowオブジェクトへのアクセスは慎重に。
キャッシュの挙動
落とし穴 7:クライアントルーターキャッシュの罠
状況:
記事一覧から詳細ページへ飛び、編集して保存。一覧に戻ると……タイトルが古いまま。F5 でリロードすると直る。
なぜハマるのか:
App Router には Client Router Cache という仕組みがあり、一度訪れたページのデータ(RSC Payload)をブラウザメモリに一時保存します。revalidatePath しても、ブラウザが持っているキャッシュまでは消せません。
解決策:router.refresh() を使います。
'use client'
import { useRouter } from 'next/navigation'
export function EditForm() {
const router = useRouter()
async function handleSubmit() {
await updatePost(...)
router.refresh() // 現在のルートのデータをサーバーから再取得
router.push('/posts')
}
}なお、Next.js 15 ではこのキャッシュ挙動が見直され、デフォルトでキャッシュされなくなりました(素晴らしい!)。
落とし穴 8:revalidate が効かない
状況:export const revalidate = 60 と書いたのに、データが更新されない。
原因:revalidate 設定は、静的に生成されたページ(SSG) に対してのみ機能します。もしページ内で cookies() や headers()、あるいは検索パラメータ (searchParams) を使用していると、そのページは「動的レンダリング」モードに切り替わり、revalidate 設定は無視されます。
対策:
ビルドログを確認し、ページが λ (Dynamic) になっていないか確認しましょう。もし動的要素が必要なら、ページ全体ではなく fetch 単位でキャッシュ制御を行います。
// fetch 単位でキャッシュ設定
fetch('...', { next: { revalidate: 60 } })エラー処理の落とし穴
落とし穴 9:error.tsx に ‘use client’ を付け忘れる
状況:error.tsx を作ったのに、エラー画面が表示されずにアプリがクラッシュする。
エラー:ReactServerComponentsError: Client Component must be used in a Client Component boundary.
なぜハマるのか:error.tsx は内部で React の Error Boundary を使用しています。Error Boundary はクラスコンポーネントの機能であり、クライアントサイドでのみ動作します。
正解:error.tsx の1行目には必ず 'use client' を書きましょう。これは義務です。
移行時の落とし穴
落とし穴 11:404.js と 500.js が機能しない
Pages Router から移行した際、pages/404.js を残していても App Router では無視されます。
404.js→app/not-found.tsx500.js→app/error.tsx- 全体エラー →
app/global-error.tsx
ファイル名と場所が変わっているので注意してください。
まとめ
Next.js App Router は強力ですが、従来の React 開発や Pages Router のメンタルモデルとは異なる部分が多くあります。
- Server Component がデフォルト:クライアント処理が必要な時だけ
'use client'。 - キャッシュファースト:データはキャッシュされる前提で、必要な時だけ再検証する。
- サーバーファースト:API を作らず、DB を直接叩く。
これらの「癖」を理解すれば、開発効率は飛躍的に上がります。私の失敗談が、あなたのデバッグ時間を少しでも減らせることを祈っています。
App Router トラブルシューティングフロー
よくある問題の特定と解決ステップ
⏱️ Estimated time: 15 min
- 1
Step1: Client/Server コンポーネントの確認
エラーが出たらまず `'use client'` の有無を確認。Hooks やブラウザ API を使うなら Client Component、それ以外は Server Component に。 - 2
Step2: データ更新の確認
データが反映されない場合、Server Action 後に `revalidatePath` または `router.refresh()` を呼んでいるかチェック。 - 3
Step3: ビルドログの確認
`npm run build` を実行し、各ページが Static (○) か Dynamic (λ) かを確認。意図しない Dynamic 化がないかチェック。 - 4
Step4: 環境変数の確認
ブラウザで値が undefined になる場合、環境変数名に `NEXT_PUBLIC_` プレフィックスが付いているか確認。
FAQ
Server Component と Client Component、どっちを使うべき?
layout.tsx でデータを取得してもいいですか?
Prisma などを Client Component で使えますか?
App Router は Pages Router より速いですか?
4 min read · 公開日: 2025年12月25日 · 更新日: 2026年1月22日
関連記事
Next.js ファイルアップロード完全ガイド:S3/Qiniu Cloud 署名付き URL 直接アップロード実践

Next.js ファイルアップロード完全ガイド:S3/Qiniu Cloud 署名付き URL 直接アップロード実践
Next.js Eコマース実践:カートと Stripe 決済の完全実装ガイド

Next.js Eコマース実践:カートと Stripe 決済の完全実装ガイド
Next.js ユニットテスト実践:Jest + React Testing Library 完全設定ガイド


コメント
GitHubアカウントでログインしてコメントできます