本文へスキップ
Next.js App Router 実務ベストプラクティス(Next.js 15〜16対応)のアイキャッチ画像
C# Tips

Next.js App Router 実務ベストプラクティス(Next.js 15〜16対応)

公開: 更新: 約10分で読めます

App Router は Next.js 13 で導入されて以降、React Server Components(RSC)を中核に据えた設計として成熟してきました。Next.js 15 から 16 にかけては、キャッシュのデフォルト挙動、非同期のリクエスト API、use cache、Partial Prerendering、Turbopack といった要素が実務レベルで固まっています。本稿では、これらの変更点を踏まえ、現場で判断に迷いやすいポイントに絞ってベストプラクティスを整理します。前提として Next.js 15.x 以降、React 19 を対象とします。

サーバーコンポーネントとクライアントコンポーネントの分離

App Router では、コンポーネントは既定でサーバーコンポーネントとして扱われます。'use client' を宣言したファイル、およびそこから import されるツリーだけがクライアントバンドルに含まれます。設計の出発点は「サーバーを既定とし、インタラクティブ性が必要な葉(リーフ)だけをクライアント化する」という原則です。

よくある失敗は、状態やイベントハンドラを持つ最上位のコンポーネントに 'use client' を付け、そこから配下すべてをクライアント化してしまうことです。データ取得や重い依存は可能な限りサーバー側に残し、クライアントコンポーネントには children やシリアライズ可能な props として結果だけを渡します。この「クライアントは境界、サーバーは中身」という受け渡しにより、バンドルサイズと Hydration コストを抑えられます。

// app/posts/page.tsx — サーバーコンポーネント(既定)
import { getPosts } from '@/lib/posts'
import { PostSearch } from './post-search'

export default async function PostsPage() {
  const posts = await getPosts()
  // 取得結果を props で渡す。PostSearch だけがクライアント側で動く
  return (
    <section>
      <h1>記事一覧</h1>
      <PostSearch posts={posts} />
    </section>
  )
}
// app/posts/post-search.tsx — クライアント境界
'use client'
import { useState } from 'react'
import type { Post } from '@/lib/posts'

export function PostSearch({ posts }: { posts: Post[] }) {
  const [q, setQ] = useState('')
  const filtered = posts.filter((p) => p.title.includes(q))
  return (
    <div>
      <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="検索" />
      <ul>{filtered.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
    </div>
  )
}

秘匿情報の扱いにも注意が必要です。クライアントコンポーネントに渡す props はネットワークに乗るため、API キーや個人情報を誤って渡さないよう、サーバー専用のモジュールには import 'server-only' を付けて境界を明示すると安全です。

Next.js App Router のレンダリングの流れを示す図。サーバーコンポーネントでのデータフェッチとキャッシュ、クライアントコンポーネントの境界、Server Actions、Suspense によるストリーミング、Partial Prerendering がブラウザへ届くまでを表す
App Router のレンダリングはサーバーコンポーネントを起点にクライアント境界とストリーミングへと段階的に広がっていく

データフェッチと新しいキャッシュモデル

Next.js 15 での最大の変更点は、キャッシュのデフォルトが「キャッシュしない」方向に切り替わったことです。fetch は既定でキャッシュされず、GET のルートハンドラも静的化されなくなりました。以前は暗黙にキャッシュされていた挙動が驚きの原因になりやすかったため、明示的な指定を促す方向へ整理されたと理解しておくとよいでしょう。

実務では、キャッシュの意図をコード上に明記します。再検証したいデータには next.revalidate、常に最新を返したいデータには cache: 'no-store' を指定します。

// 一定時間ごとに再検証(ISR 相当)
const posts = await fetch('https://api.example.com/posts', {
  next: { revalidate: 3600 },
}).then((r) => r.json())

// 常に最新(キャッシュしない)
const me = await fetch('https://api.example.com/me', {
  cache: 'no-store',
}).then((r) => r.json())

// 明示的にキャッシュし、タグで無効化できるようにする
const config = await fetch('https://api.example.com/config', {
  cache: 'force-cache',
  next: { tags: ['config'] },
}).then((r) => r.json())

タグを付けたデータは、Server Action やルートハンドラから revalidateTag('config') でピンポイントに無効化できます。パス单位でよければ revalidatePath('/dashboard') を使います。更新系の処理の直後にこれらを呼ぶのが基本形です。

同一レンダー内で同じ URL・同じオプションの fetch は自動的に重複排除(メモ化)されます。fetch を使わない DB 直アクセスなどでリクエスト単位のメモ化が欲しい場合は、React の cache() でラップします。

import { cache } from 'react'
import { db } from '@/lib/db'

// 同一リクエスト内で複数回呼んでも DB アクセスは一度だけ
export const getUser = cache(async (id: string) => {
  return db.user.findUnique({ where: { id } })
})

さらに Next.js 15 後半から 16 では use cache ディレクティブが導入され、関数やコンポーネント、ファイル単位でキャッシュ境界を宣言できます。fetch に依存しないデータ取得もまとめてキャッシュ対象にできる点が利点です。有効化には設定(cacheComponents あるいは dynamicIO 系のフラグ)が必要なため、採用時はプロジェクトの安定性方針と合わせて判断します。

// app/lib/metrics.ts
export async function getMetrics(range: string) {
  'use cache'
  const rows = await db.metrics.aggregate({ range })
  return rows
}

Server Actions とルートハンドラの使い分け

フォーム送信やデータ更新は Server Actions で完結させるのが App Router の標準的なパターンです。'use server' を付けた非同期関数を <form action={...}> に渡すだけで、クライアントに個別の API エンドポイントを用意せずに更新処理を書けます。クライアント側の状態管理は React 19 の useActionState(旧 useFormState)で扱います。

// app/invoices/actions.ts
'use server'
import { z } from 'zod'
import { revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'

const schema = z.object({
  customerId: z.string().min(1, '顧客を選択してください'),
  amount: z.coerce.number().gt(0, '0より大きい金額を入力してください'),
})

export type FormState = { error?: string }

export async function createInvoice(_prev: FormState, formData: FormData): Promise<FormState> {
  const session = await auth()
  if (!session?.user) return { error: '認証が必要です' }

  const parsed = schema.safeParse(Object.fromEntries(formData))
  if (!parsed.success) return { error: parsed.error.issues[0].message }

  await db.invoice.create({ data: parsed.data })
  revalidateTag('invoices')
  redirect('/invoices')
}
// app/invoices/create-form.tsx
'use client'
import { useActionState } from 'react'
import { createInvoice, type FormState } from './actions'

export function CreateForm() {
  const [state, action, pending] = useActionState<FormState, FormData>(createInvoice, {})
  return (
    <form action={action}>
      {/* フィールド省略 */}
      {state.error && <p role="alert">{state.error}</p>}
      <button disabled={pending}>{pending ? '送信中' : '作成'}</button>
    </form>
  )
}

Server Action で必ず守るべきは、入力検証と認可をアクション内部で行うことです。Server Action は公開エンドポイントとして呼び出せるため、UI 側の制御だけに頼ってはいけません。検証には Zod などを使い、認証済みユーザーであることをアクションの冒頭で確認します。

一方、Webhook の受け口、サードパーティ連携、外部クライアント向けの REST/JSON API といった用途には route.ts のルートハンドラが適しています。ブラウザ内のフォームやミューテーションは Server Action、外部との HTTP インターフェースはルートハンドラ、という切り分けを基準にすると迷いにくくなります。

ストリーミング、Suspense、Partial Prerendering

時間のかかるデータ取得は、ページ全体の表示を待たせずに Suspense で段階的にストリーミングします。境界ごとにフォールバックを出し、準備できた部分から順に描画することで、体感速度と Core Web Vitals を改善できます。

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { Revenue, RevenueSkeleton } from './revenue'
import { LatestOrders, OrdersSkeleton } from './latest-orders'

export default function DashboardPage() {
  return (
    <main>
      <h1>ダッシュボード</h1>
      <Suspense fallback={<RevenueSkeleton />}>
        <Revenue />
      </Suspense>
      <Suspense fallback={<OrdersSkeleton />}>
        <LatestOrders />
      </Suspense>
    </main>
  )
}

各セグメント直下に置く loading.tsx は、その配下を暗黙的に Suspense で包むショートカットです。ページ遷移時のローディング UI を簡潔に定義できます。並行して複数のデータを取る場合は、直列の await を避けて Promise.all でまとめると待ち時間を短縮できます。

Partial Prerendering(PPR)は、静的なシェルをビルド時にプリレンダーしつつ、動的な部分だけをリクエスト時にストリーミングで埋める仕組みです。1 ページの中で「静的に速く出せる枠」と「ユーザーごとに変わる中身」を両立させられる点が特徴です。PPR は実験的機能として提供されており、next.config で experimental.ppr を 'incremental' に設定し、対象ルートで export const experimental_ppr = true を宣言して段階的に導入します。動的な箇所を Suspense で明確に囲む設計が前提になります。

メタデータ、画像・フォント最適化、Turbopack

SEO のためのメタデータは Metadata API で型安全に定義します。静的なページは metadata オブジェクト、動的なページは generateMetadata を使い、記事ごとのタイトルや OG 画像を生成します。generateStaticParams と組み合わせれば、対象パスをビルド時に静的生成できます。

// app/tech/[slug]/page.tsx
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPost, getAllSlugs } from '@/lib/posts'

// Next.js 15 以降、params は Promise。await して取り出す
export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> },
): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) return { title: '記事が見つかりません' }
  return {
    title: post.title,
    description: post.excerpt,
    alternates: { canonical: `/tech/${slug}` },
    openGraph: { title: post.title, type: 'article', images: [post.ogImage] },
  }
}

export async function generateStaticParams() {
  return (await getAllSlugs()).map((slug) => ({ slug }))
}

export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) notFound()
  return <article>{/* 本文 */}</article>
}

ここで押さえておきたいのが、Next.js 15 で params、searchParams、および cookies()・headers() が非同期(Promise)になった点です。既存コードを移行する際は await の付け忘れが典型的なエラー源になります。

画像は next/image の <Image> を使い、レイアウトシフトを防ぐために width/height(またはレスポンシブ時の fill と sizes)を指定します。ファーストビューの主要画像には priority を付け、それ以外は遅延読み込みに任せます。フォントは next/font でセルフホストし、display: 'swap' と CSS 変数化により、外部リクエストを増やさずに FOUT を抑えられます。

// app/layout.tsx
import { Inter, Noto_Sans_JP } from 'next/font/google'

const inter = Inter({ subsets: ['latin'], display: 'swap', variable: '--font-inter' })
const notoJp = Noto_Sans_JP({ subsets: ['latin'], display: 'swap', variable: '--font-noto' })

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja" className={`${inter.variable} ${notoJp.variable}`}>
      <body>{children}</body>
    </html>
  )
}

ビルドツールは Turbopack が実用段階に入りました。Next.js 15 では開発サーバー(next dev --turbopack)が安定版となり、16 では Turbopack がビルドの既定として据えられる流れにあります。大規模プロジェクトほど起動と再ビルドの速さが効いてきます。導入時は、カスタム webpack 設定を使っている箇所の互換性を確認し、段階的に切り替えるのが安全です。

認証と実務で踏みやすい落とし穴

認証はサーバー側でセッションを検証し、その結果に基づいてレンダリングやアクセス制御を行うのが基本です。middleware はセッション Cookie の有無による軽量なリダイレクトに向いていますが、認可の最終判断をミドルウェアだけに委ねるのは避けます。ページ、レイアウト、Server Action の各所で、実際にデータへ触れる直前に認可チェックを置く「多層防御」が堅実です。認証ライブラリは Auth.js(NextAuth v5)などが App Router に対応しています。

// データアクセスの直前で認可を確認する
import { auth } from '@/lib/auth'
import { forbidden } from 'next/navigation'

export async function getTeamInvoices(teamId: string) {
  const session = await auth()
  if (!session?.user) throw new Error('unauthenticated')
  if (!session.user.teamIds.includes(teamId)) forbidden()
  return db.invoice.findMany({ where: { teamId } })
}

最後に、移行や新規構築で繰り返し見かける落とし穴をまとめます。

  • 過剰なクライアント化 — ツリー上位に 'use client' を置いて配下全体をバンドルに含めてしまう。境界は葉に寄せる。
  • キャッシュの誤解 — 「なぜか古いデータが出る/逆に毎回取りにいく」の多くは revalidate と no-store の指定漏れが原因。意図をコードに明記する。
  • 非同期 API の await 漏れ — params や cookies() が Promise になった点を見落とす。
  • Server Action の検証不足 — UI 制御だけに頼り、アクション内部の入力検証と認可を省く。
  • 再検証の呼び忘れ — 更新後に revalidateTag/revalidatePath を呼ばず、画面が更新されない。
  • 秘匿情報の流出 — サーバー専用モジュールに server-only を付けず、クライアントへ機密を渡してしまう。

エンハンスド株式会社では、Next.js App Router を用いたモダン Web 開発と、フロントエンドの内製化支援を行っています。設計方針の策定やレンダリング戦略の見直し、既存プロジェクトの Next.js 15/16 への移行、社内チームが継続して開発を進められる体制づくりまで、実装と伴走の両面から支援します。技術選定や現状のアーキテクチャに課題を感じている場合は、お気軽にご相談ください。

この記事をシェア

コピーしました

関連記事