言語を切り替える
テーマを切り替える

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 の落とし穴の避け方が身についているはずです。

データ取得の落とし穴

落とし穴 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 → クライアントと、不要なネットワーク往復が 1 回増えています。

なぜハマるのか

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 リクエストが 1 回減る
  • サーバーから DB へのレイテンシは通常 10ms 未満(クライアントからサーバーへは 100ms 以上かかることも)
  • クライアントの JavaScript バンドルが小さくなる

要点

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 のデフォルトがキャッシュなしに変わりました。Next.js 14 を使っている場合は、次のように書けます。

// 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 } })
  // 再検証を忘れている!
}

フォーム送信後も古いデータのまま。手動リロードしないと新しいタスクが見えません。

なぜハマるのか

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 パスを再検証
}

応用テクニック

Todo リストを複数ページ(トップ、アーカイブなど)で表示しているなら、revalidateTag の方が柔軟です。

// app/todos/page.tsx
export default async function TodosPage() {
  const todos = await fetch('http://localhost:3000/api/todos', {
    next: { tags: ['todos'] } // データにタグを付ける
  })
  return <TodoList todos={todos} />
}

// app/actions.ts
'use server'
import { revalidateTag } from 'next/cache'

export async function addTodo(text: string) {
  await db.todo.create({ data: { text } })
  revalidateTag('todos') // 'todos' タグ付きデータをすべて再検証
}

要点

データ更新の三ステップ:書き込み → revalidatePath / revalidateTag → リダイレクト(任意)

Server Components と Client Components の混乱

落とし穴 4:Server Component で Context を使う

状況再現

テーマ切り替えをグローバルに提供したくて、ThemeProvider を書きました。

// app/providers.tsx
import { createContext } from 'react'

export const ThemeContext = createContext('light')

export function Providers({ children }) {
  return (
    <ThemeContext.Provider value="dark">
      {children}
    </ThemeContext.Provider>
  )
}

// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

エラーが出ました: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>
  )
}

// app/layout.tsx(Server Component のまま)
import { Providers } from './providers'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

私が踏んだ落とし穴

最初は 'use client'layout.tsx に付けてしまいました。アプリ全体が Client Component になり、Server Components のメリットが消えてしまったのです。Provider だけ Client Component にし、Layout は Server Component のまま——これを覚えておいてください。

落とし穴 5:Client Component の SSR 誤解

状況再現

Client Component で localStorage を使いました。ローカル開発では問題ないのに、デプロイ後に localStorage is not defined エラーが出ました。

// app/components/user-info.tsx
'use client'

export default function UserInfo() {
  const user = JSON.parse(localStorage.getItem('user') || '{}')
  return <div>{user.name}</div>
}

なぜハマるのか

'use client' は「クライアントだけで動く」という意味ではありません。Client Components もサーバーでプリレンダリング(SSR)されますlocalStorage はブラウザにしかなく、サーバーからアクセスすると当然エラーになります。

解決策 1:環境をチェック

'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>
}

解決策 2:条件分岐

'use client'

export default function UserInfo() {
  const user = typeof window !== 'undefined'
    ? JSON.parse(localStorage.getItem('user') || '{}')
    : null

  if (!user) return null
  return <div>{user.name}</div>
}

要点

Client Component = クライアントでインタラクションできるコンポーネント。それでもサーバーでプリレンダリングされる。ブラウザ API(localStorage、window、document)は useEffect 内か、環境チェック後に使う。

落とし穴 6:'use client' の使いすぎ

状況再現

App Router を始めた頃、エラーが出るたびに 'use client' を付ける癖がつき、プロジェクト全体が Client Components だらけになりました。Server Components のメリットがほぼ消えていました。

なぜハマるのか

Client Component が必要に見える問題の多くは、コードの分割の問題にすぎません。

悪い例

// app/dashboard/page.tsx
'use client' // 付けるべきではない!

import { useState } from 'react'

export default function Dashboard() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <Header /> {/* 静的 */}
      <Stats /> {/* サーバーデータが必要 */}
      <Counter count={count} setCount={setCount} /> {/* インタラクションが必要 */}
    </div>
  )
}

こう書くとページ全体が Client Component になり、Stats のデータもクライアント fetch になります。

正しいやり方

// app/dashboard/page.tsx(Server Component)
import { db } from '@/lib/db'
import { Counter } from './counter'

export default async function Dashboard() {
  const stats = await db.stats.findFirst() // サーバーでデータ取得

  return (
    <div>
      <Header /> {/* Server Component */}
      <Stats data={stats} /> {/* Server Component */}
      <Counter /> {/* Client Component */}
    </div>
  )
}

// app/dashboard/counter.tsx
'use client' // このコンポーネントだけ Client Component

import { useState } from 'react'

export function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

私の判断基準

'use client' が必要かどうか、3 つで判断します。

  1. React Hooks(useState、useEffect、useContext など)を使う
  2. ブラウザイベント(onClick、onChange など)をリッスンする
  3. ブラウザ API(localStorage、window など)を使う

この 3 つに当てはまらなければ、Server Component のままにします。

キャッシュの落とし穴

落とし穴 7:Client Router Cache の混乱

状況再現

ユーザーが /posts/1 で記事を編集し、保存後 /posts 一覧に戻ると、タイトルが古いまま。ページをリロードすると更新されます。

なぜハマるのか

App Router には Client Router Cache(クライアントルートキャッシュ)があり、訪問済みページをキャッシュします。データが更新されても、遷移時には古いキャッシュが表示されることがあります。

解決策 1:遷移時にリフレッシュ

// app/posts/[id]/edit/page.tsx
'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

export async function updatePost(id: string, title: string) {
  await db.post.update({ where: { id }, data: { title } })

  revalidatePath('/posts') // 一覧ページを再検証
  revalidatePath(`/posts/${id}`) // 詳細ページを再検証

  redirect('/posts') // 一覧へ戻る
}

解決策 2: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 の朗報

Next.js 15 では Client Router Cache のデフォルトがキャッシュなしに変わりました。この問題はほぼ気にしなくてよくなります。Next.js 14 なら、手動で対処してください。

落とし穴 8:revalidate が効かない

状況再現

Server Component で revalidate = 60 を設定し、60 秒ごとにデータが更新されるはずだったのに、ずっと変わりません。

// app/news/page.tsx
export const revalidate = 60 // 60 秒後に再生成を期待

export default async function NewsPage() {
  const news = await fetch('https://api.example.com/news')
  return <NewsList news={news} />
}

デプロイ後、ニュース一覧が丸一日更新されませんでした。

なぜハマるのか

revalidate本番環境でのみ有効です。開発環境(npm run dev)ではキャッシュされません。また、静的生成されたページにのみ効き、動的レンダリングと判定されたページでは無効になります。

確認手順

  1. 本番環境で確認
npm run build
npm run start
  1. ページが静的か確認

ビルド出力で ○ Static または ● SSG になっているか確認。λ Dynamic なら動的レンダリングです。

  1. 動的レンダリングの原因を特定

よくある原因:

  • cookies()headers() を使っている
  • searchParams(動的ルートパラメータ)を使っている
  • Route Handler に revalidate が設定されていない

解決策

// app/news/page.tsx
export const revalidate = 60

export default async function NewsPage() {
  const news = await fetch('https://api.example.com/news', {
    next: { revalidate: 60 } // fetch レベルの revalidate
  })

  return <NewsList news={news} />
}

私の運用ルール

  • 純粋な静的コンテンツgenerateStaticParams + revalidate
  • 動的パラメータが必要:ISR(Incremental Static Regeneration)
  • リアルタイムデータdynamic = 'force-dynamic' を付け、revalidate は使わない

エラー処理の落とし穴

落とし穴 9:error.tsx に 'use client' を付け忘れる

状況再現

error.tsx を作ってページエラーを処理しようとしたら、エラー:ReactServerComponentsError: Client Component must be used in a Client Component boundary.

// app/error.tsx(誤った書き方)
export default function Error({ error, reset }) {
  return (
    <div>
      <h2>エラーが発生しました!</h2>
      <button onClick={reset}>再試行</button>
    </div>
  )
}

なぜハマるのか

error.tsx は Client Component である必要があります。React の Error Boundary 機構を使うためで、Error Boundary はクライアントでのみ動作します。

正しい書き方

// app/error.tsx
'use client' // 必須

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div>
      <h2>エラーが発生しました!</h2>
      <p>{error.message}</p>
      <button onClick={reset}>再試行</button>
    </div>
  )
}

要点

error.tsxloading.tsxnot-found.tsx などの特殊ファイルのうち、Client Component が必須なのは error.tsx だけ。他は Server Component でも構いません。

落とし穴 10:try/catch 内での redirect の位置

状況再現

Server Action でフォーム検証を行い、失敗時にエラーページへリダイレクトしようとしたら、redirect が catch に捕捉されて失敗しました。

// app/actions.ts(誤った書き方)
'use server'
import { redirect } from 'next/navigation'

export async function createUser(data: FormData) {
  try {
    const user = await db.user.create({ data })
    redirect(`/users/${user.id}`) // ここが catch に捕捉される!
  } catch (error) {
    console.error(error)
    return { error: 'Failed to create user' }
  }
}

なぜハマるのか

redirect() は特殊なエラーを throw して実装されています。Next.js がそれを捕捉してリダイレクトを実行します。try/catch 内で redirect すると、あなたの catch が先に捕捉してしまい、リダイレクトが失敗します。

正しい書き方

// app/actions.ts
'use server'
import { redirect } from 'next/navigation'

export async function createUser(data: FormData) {
  try {
    const user = await db.user.create({ data })
    // ここでは redirect しない
    return { success: true, userId: user.id }
  } catch (error) {
    console.error(error)
    return { error: 'Failed to create user' }
  }
}

// 呼び出し側で redirect
export async function handleSubmit(data: FormData) {
  const result = await createUser(data)
  if (result.success) {
    redirect(`/users/${result.userId}`) // try/catch の外で
  }
}

別パターン

'use server'
import { redirect } from 'next/navigation'

export async function createUser(data: FormData) {
  try {
    const user = await db.user.create({ data })
  } catch (error) {
    console.error(error)
    return { error: 'Failed to create user' }
  }

  redirect(`/users/${user.id}`) // try/catch の後
}

移行時の落とし穴

落とし穴 11:404.js と 500.js が使えなくなった

状況再現

Pages Router から移行する際、pages/404.jspages/500.js を残していましたが、どちらも効きませんでした。

なぜハマるのか

App Router ではエラー処理の仕組みが完全に変わりました。

  • 404.jsnot-found.tsx
  • 500.jserror.tsx
  • グローバルエラー → global-error.tsx

正しいやり方

// app/not-found.tsx
export default function NotFound() {
  return (
    <div>
      <h2>404 - ページが見つかりません</h2>
      <Link href="/">ホームに戻る</Link>
    </div>
  )
}

// app/error.tsx
'use client'

export default function Error({ error, reset }) {
  return (
    <div>
      <h2>500 - サーバーエラー</h2>
      <p>{error.message}</p>
      <button onClick={reset}>再試行</button>
    </div>
  )
}

// app/global-error.tsx(ルートレイアウトのエラーを捕捉)
'use client'

export default function GlobalError({ error, reset }) {
  return (
    <html>
      <body>
        <h2>グローバルエラー</h2>
        <p>{error.message}</p>
        <button onClick={reset}>再試行</button>
      </body>
    </html>
  )
}

落とし穴 12:next-seo が使えなくなった

状況再現

プロジェクトで next-seo を SEO メタデータ管理に使っていましたが、App Router に移行したら効かなくなりました。

// pages/blog/[slug].tsx(Pages Router 時代)
import { NextSeo } from 'next-seo'

export default function BlogPost({ post }) {
  return (
    <>
      <NextSeo
        title={post.title}
        description={post.excerpt}
        openGraph={{
          title: post.title,
          description: post.excerpt,
          images: [{ url: post.coverImage }],
        }}
      />
      <article>{post.content}</article>
    </>
  )
}

なぜハマるのか

App Router にはネイティブの generateMetadata API があり、next-seo は非推奨になりました。

移行方法

// app/blog/[slug]/page.tsx
import { Metadata } from 'next'

export async function generateMetadata({ params }): Promise<Metadata> {
  const post = await getPost(params.slug)

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  }
}

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug)
  return <article>{post.content}</article>
}

メリット:

  1. 型安全(TypeScript サポート)
  2. async/await 対応(DB を直接クエリできる)
  3. パフォーマンス向上(サーバーサイドレンダリング)

パフォーマンス最適化の提案

不要な Client Components を避ける

問題:ページ全体が Client Component になり、Server Components のメリットが失われる。

解決策

「リーフノード Client Components」戦略を採用します。

// ❌ 悪い例
// app/dashboard/page.tsx
'use client'
export default function Dashboard() {
  return (
    <div>
      <Header />
      <Sidebar />
      <MainContent />
      <Footer />
    </div>
  )
}

// ✅ 良い例
// app/dashboard/page.tsx(Server Component)
import { Header } from './header'
import { Sidebar } from './sidebar'
import { MainContent } from './main-content'
import { Footer } from './footer'

export default function Dashboard() {
  return (
    <div>
      <Header /> {/* Server Component */}
      <Sidebar /> {/* Client Component(インタラクション) */}
      <MainContent /> {/* Server Component */}
      <Footer /> {/* Server Component */}
    </div>
  )
}

// app/dashboard/sidebar.tsx
'use client' // これだけ Client Component
export function Sidebar() {
  const [collapsed, setCollapsed] = useState(false)
  return <aside>...</aside>
}

Suspense 境界の最適化

問題:ページ全体が遅いデータを待ち、初回表示が白画面のまま長引く。

解決策

<Suspense> で読み込みを分割します。

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { FastComponent } from './fast'
import { SlowComponent } from './slow'

export default function Dashboard() {
  return (
    <div>
      {/* 速いデータはすぐ表示 */}
      <FastComponent />

      {/* 遅いデータはスケルトンを表示 */}
      <Suspense fallback={<div>読み込み中...</div>}>
        <SlowComponent />
      </Suspense>
    </div>
  )
}

並列データ取得を活用する

問題:データを直列取得すると、合計時間 = すべてのリクエスト時間の合計になる。

解決策

// ❌ 直列取得(遅い)
export default async function Page() {
  const user = await getUser() // 100ms
  const posts = await getPosts() // 200ms
  const comments = await getComments() // 150ms
  // 合計:450ms
}

// ✅ 並列取得(速い)
export default async function Page() {
  const [user, posts, comments] = await Promise.all([
    getUser(),
    getPosts(),
    getComments(),
  ])
  // 合計:200ms(最も遅いリクエストに依存)
}

開発環境の落とし穴

落とし穴 13:ホットリロードによる接続リーク

状況再現

開発環境をしばらく動かすと、DB が too many connections エラーを出しました。

なぜハマるのか

ホットリロード(Hot Reload)はモジュールコードを再実行します。グローバルに DB 接続を作ると、リロードのたびに新しい接続が作られ、古い接続は閉じられません。

解決策

// lib/db.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = global as unknown as { prisma: PrismaClient }

export const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({
    log: ['query'],
  })

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma
}

開発環境ではホットリロード時に同じ Prisma インスタンスを再利用できます。

落とし穴 14:開発サーバーがだんだん遅くなる

状況再現

npm run dev を 30 分ほど動かすと、ホットリロードが非常に遅くなり、ときどきフリーズします。

なぜハマるのか

App Router の開発サーバーは、ページ数が多いと特にメモリを大量に消費します。動的ルートが多いほど顕著です。

一時的な対処

  1. 開発サーバーを再起動(根本解決にはならない)
  2. 不要なファイル監視を減らす:
// next.config.js
module.exports = {
  webpack: (config) => {
    config.watchOptions = {
      poll: 1000, // 1 秒ごとにファイル変更をチェック(頻度を下げる)
      aggregateTimeout: 300,
      ignored: /node_modules/,
    }
    return config
  },
}

長期的な解決策

Next.js 15 にアップグレードし、Turbopack を使います。

npm run dev --turbo

Turbopack のホットリロードは 10 倍以上速く、大規模プロジェクトでも快適です。

まとめ:落とし穴回避チェックリスト

ここまで多くの落とし穴を見てきました。新プロジェクトを始める前にこのリストを確認すれば、90% の問題を避けられます。

データ取得

  • ☑ Server Component で取得できるデータは、Client Component + useEffect を使わない
  • ☑ Route Handler に dynamic = 'force-dynamic' または revalidate を明示
  • ☑ データ変更後は revalidatePath / revalidateTag を忘れない

Server / Client Components

  • ☑ Provider は Client Component、Layout は Server Component のまま
  • ☑ ブラウザ API(localStorage、window)は useEffect 内か環境チェック後に使う
  • ☑ 本当にインタラクションが必要なコンポーネントだけ 'use client' を付ける。ページ全体に付けない

エラー処理

  • error.tsx には必ず 'use client' を付ける
  • redirect は try/catch の中に書かない
  • ☑ ルートレイアウトのエラーは global-error.tsx で処理(error.tsx ではない)

キャッシュ

  • ☑ Next.js 15 にアップグレードし、より合理的なデフォルトキャッシュを活用
  • ☑ revalidate は本番環境でのみ有効。開発環境では依存しない
  • ☑ 動的ページでは revalidate を使わず、dynamic = 'force-dynamic' を使う

移行関連

  • 404.jsnot-found.tsx500.jserror.tsx
  • next-seogenerateMetadata
  • getServerSideProps → Server Component で直接 fetch
  • useRouternext/router から next/navigation

パフォーマンス最適化

  • ☑ Suspense で速い/遅いコンポーネントを分割
  • ☑ データ取得は並列化(Promise.all
  • ☑ 開発環境では DB 接続をシングルトンにする
  • ☑ Turbopack を使う(npm run dev --turbo

最後に

App Router には確かに学習曲線があります。最初は落とし穴にハマるのが普通です。でも、これらのパターンを身につければ、開発効率は確実に上がります。

今の私の習慣はこうです。

  1. 新機能はまずデータフローを考える:サーバーレンダリングが必要か、クライアントインタラクションか?
  2. 問題が出たらまずビルド出力を見る:Static か Dynamic か?なぜ?
  3. DevTools を活用:Network パネルでリクエスト数、Console でエラースタック
  4. デフォルト挙動に頼らない:キャッシュ、レンダリング方式、再検証の意図を明示する

一番大事なのは、これらの落とし穴を恐れず、実際に手を動かすこと。1 回踏めば、次からは覚えています。Next.js App Router の公式ドキュメントも詳しいので、困ったらドキュメントを見れば、だいたい答えがあります。

この記事が役に立ったら、同じ轍を踏んでいる友人にもシェアしてください。新しい落とし穴があればコメントで教えてください。リストは随時更新していきます。

App Router の道で、遠回りを減らし、よりエレガントなコードを書けることを祈っています!

FAQ

Server Component と Client Component はどう使い分ける?
Server Component(デフォルト):
• サーバーで実行され、クライアントには送られない
• useState、useEffect などの Hooks は使えない
• ブラウザ API は使えない

Client Component(明示が必要):
• `'use client'` ディレクティブでマークする
• すべての React Hooks が使える
• ブラウザ API が使える

判断基準:インタラクションやブラウザ API が必要なら Client Component
データが更新されないのはなぜ?
Next.js の fetch はデフォルトでキャッシュされます。

解決策:
• cache: 'no-store' を設定(リクエストごとに最新データを取得)
• next: { revalidate: 60 } を使う(60 秒後に再検証)
• Client Component で router.refresh() を呼び、強制リフレッシュ

確認方法:ビルド出力でページが Dynamic か Static かを確認
ページがずっとローディングのままになる
考えられる原因:
• 非同期 Server Component で loading 状態を正しく処理していない
• Suspense 境界の設定ミス
• データ取得失敗時のエラー処理がない

対処法:
• loading.tsx を追加
• 非同期コンポーネントを Suspense でラップ
• error.tsx でエラーを処理
error.tsx が効かない
error.tsx は Client Component である必要があります。

必ず `'use client'` を追加:
'use client'

export default function Error({ error, reset }) {
return <div>エラー:{error.message}</div>
}

注意:error.tsx は子コンポーネントのエラーしか捕捉できず、自身のエラーは捕捉できません
Pages Router から App Router へどう移行する?
主な変更点:
• getServerSideProps → 非同期 Server Component
• getStaticProps → 静的生成(デフォルト)
• next/router → next/navigation
• _app.js → layout.tsx
• _document.js → 不要(layout.tsx が担当)

推奨:まず 1〜2 ページで試し、流れを確認してから本格移行
キャッシュ機構はどう理解すればいい?
Next.js のキャッシュ階層:
• Request Memoization:同一リクエスト内で同じ fetch は 1 回だけ実行
• Data Cache:fetch のレスポンスがキャッシュされる
• Full Route Cache:ページ全体がキャッシュされる(静的生成)
• Router Cache:クライアント側のルートキャッシュ

制御方法:cache: 'no-store'、next: { revalidate } などのオプションを使う
App Router の問題はどうデバッグする?
デバッグ方法:
• ビルド出力(npm run build)でページタイプを確認
• DevTools の Network パネルでリクエストを確認
• Console のエラーメッセージを確認
• Next.js ターミナル出力を確認

よくある問題:
• Static なのに Dynamic であるべき → fetch のキャッシュ設定を確認
• データが更新されない → キャッシュと revalidate 設定を確認
• ページがローディングのまま → loading.tsx と Suspense を確認

5分で読めます · 公開日: 2025年12月25日 · 更新日: 2026年6月8日

関連記事

コメント

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