Skip to content
Published on

Next.js 15 + React 19完全ガイド:Server ComponentsからServer Actionsまで2025実践攻略

Authors

はじめに

React 19とNext.js 15の組(く)み合(あ)わせは、フロントエンド開発(かいはつ)のパラダイムを根本的(こんぽんてき)に変(か)えています。サーバーコンポーネントがデフォルトになり、サーバーアクションでAPIレイヤーがなくなり、TurbopackがビルドSpeed(すぴーど)を革新(かくしん)し、PPRが静的(せいてき)/動的(どうてき)レンダリングの境界(きょうかい)を取(と)り払(はら)いました。

このガイドは、Pages Router時代(じだい)のNext.jsを使(つか)っていた開発者(かいはつしゃ)、またはReact 18までしか経験(けいけん)していない開発者が最新(さいしん)スタックに移行(いこう)するための実践(じっせん)完全(かんぜん)ガイドです。概念(がいねん)の説明(せつめい)にとどまらず、実際(じっさい)のコードパターンとマイグレーション戦略(せんりゃく)までカバーします。


1. React 19のイノベーション

React 19は2024年(ねん)12月(がつ)に安定版(あんていばん)としてリリースされ、Reactの哲学(てつがく)そのものを進化(しんか)させる大規模(だいきぼ)アップデートです。

1.1 React Compiler(自動メモ化)

React Compiler(以前(いぜん)の名称(めいしょう)React Forget)は、手動(しゅどう)でuseMemo、useCallback、memoを書(か)く必要(ひつよう)をなくします。コンパイラがビルド時(じ)にコンポーネントを分析(ぶんせき)し、自動的(じどうてき)にメモ化(か)を適用(てきよう)します。

Before(React 18):

import { useMemo, useCallback, memo } from 'react'

const ExpensiveList = memo(({ items, onSelect }: Props) => {
  const sortedItems = useMemo(() => items.sort((a, b) => a.name.localeCompare(b.name)), [items])

  const handleClick = useCallback(
    (id: string) => {
      onSelect(id)
    },
    [onSelect]
  )

  return (
    <ul>
      {sortedItems.map((item) => (
        <li key={item.id} onClick={() => handleClick(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  )
})

After(React 19 + Compiler):

// React Compilerが自動的にメモ化を適用
// useMemo、useCallback、memoすべて不要
function ExpensiveList({ items, onSelect }: Props) {
  const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name))

  return (
    <ul>
      {sortedItems.map((item) => (
        <li key={item.id} onClick={() => onSelect(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  )
}

Next.js 15でReact Compilerを有効(ゆうこう)にするには:

// next.config.js
module.exports = {
  experimental: {
    reactCompiler: true,
  },
}

インストールも必要(ひつよう)です:

npm install babel-plugin-react-compiler

1.2 use() Hook — PromiseとContextの読み取り

React 19のuse()フックは、従来(じゅうらい)のフックとは異(こと)なり、条件文(じょうけんぶん)の中(なか)でも呼(よ)び出(だ)せます。PromiseをSuspenseと連動(れんどう)して直接(ちょくせつ)読(よ)み取(と)り、Contextも新(あたら)しい方法(ほうほう)で消費(しょうひ)します。

import { use, Suspense } from 'react'

// Promiseをuse()で直接読み取り
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise) // Suspense境界で自動的に待機

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  )
}

// 条件付きでContextを読み取り
function ThemeButton({ showIcon }: { showIcon: boolean }) {
  if (showIcon) {
    const theme = use(ThemeContext)
    return <Icon color={theme.primary} />
  }
  return <button>Click me</button>
}

// 使用例
function App() {
  const userPromise = fetchUser(userId) // Promise作成

  return (
    <Suspense fallback={<Loading />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  )
}

1.3 Actions:useActionState、useFormStatus、useOptimistic

React 19はフォームと非同期(ひどうき)操作(そうさ)のための専用(せんよう)フックセットを導入(どうにゅう)しました。

useActionState — フォームアクションの状態(じょうたい)を管理(かんり)します:

'use client'
import { useActionState } from 'react'
import { createTodo } from '@/actions/todo'

function TodoForm() {
  const [state, formAction, isPending] = useActionState(createTodo, {
    message: '',
    errors: {},
  })

  return (
    <form action={formAction}>
      <input name="title" placeholder="タスクを入力" />
      {state.errors?.title && <p className="error">{state.errors.title}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? '追加中...' : '追加'}
      </button>
      {state.message && <p>{state.message}</p>}
    </form>
  )
}

useFormStatus — 親(おや)フォームの送信状態(じょうたい)を子(こ)コンポーネントから読(よ)み取(と)ります:

'use client'
import { useFormStatus } from 'react-dom'

function SubmitButton() {
  const { pending, data, method } = useFormStatus()

  return (
    <button type="submit" disabled={pending}>
      {pending ? '送信中...' : '送信する'}
    </button>
  )
}

useOptimistic — 楽観的(らっかんてき)更新(こうしん)を宣言的(せんげんてき)に処理(しょり)します:

'use client'
import { useOptimistic } from 'react'

function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (currentTodos, newTodo: Todo) => [...currentTodos, newTodo]
  )

  async function handleAdd(formData: FormData) {
    const title = formData.get('title') as string
    const tempTodo = { id: crypto.randomUUID(), title, completed: false }

    addOptimisticTodo(tempTodo) // 即座にUI更新
    await createTodoAction(formData) // サーバーに実際に保存
  }

  return (
    <div>
      <form action={handleAdd}>
        <input name="title" />
        <button type="submit">追加</button>
      </form>
      <ul>
        {optimisticTodos.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </div>
  )
}

1.4 Document MetadataとStylesheetサポート

React 19では、title、meta、linkタグをコンポーネントツリーのどこでも宣言(せんげん)できます:

function BlogPost({ post }: { post: Post }) {
  return (
    <article>
      <title>{post.title}</title>
      <meta name="description" content={post.summary} />
      <link rel="stylesheet" href="/styles/blog.css" precedence="default" />
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  )
}

stylesheetのprecedence属性(ぞくせい)でCSSのロード順序(じゅんじょ)を制御(せいぎょ)し、Suspenseと連動(れんどう)してスタイルシートがロードされるまでコンテンツのレンダリングを待機(たいき)させることができます。

1.5 Ref as Prop(forwardRef不要)

React 19では、関数(かんすう)コンポーネントがrefを通常(つうじょう)のpropとして受(う)け取(と)れます:

// Before(React 18)— forwardRefが必要
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
  return <input ref={ref} {...props} />
})

// After(React 19)— refをそのままpropとして
function Input({ ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
  return <input ref={ref} {...props} />
}

2. Next.js 15のコア変更点

Next.js 15は2024年(ねん)10月(がつ)にリリースされ、React 19を公式(こうしき)サポートする最初(さいしょ)のメジャーフレームワークバージョンです。

2.1 Turbopackの安定化

Turbopackがnext dev --turbopackで安定版(あんていばん)になりました。Rustベースの増分(ぞうぶん)ビルドシステムにより:

  • ローカルサーバー起動(きどう):最大(さいだい)76.7%高速(こうそく)
  • Fast Refresh:最大96.3%高速
  • 初回(しょかい)ルートコンパイル:最大45.8%高速(キャッシュなしの場合)
{
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build"
  }
}

プロダクションビルド(next build)でのTurbopackサポートはまだ実験的(じっけんてき)で、--turbopackフラグで試(ため)せます。

2.2 非同期Request APIs

Next.js 15での最大(さいだい)の**破壊的変更(はかいてきへんこう)**です。ランタイムのRequest情報(じょうほう)にアクセスするAPIがすべて非同期(ひどうき)になりました:

// Before(Next.js 14)
import { cookies, headers } from 'next/headers'

export default function Page({ params, searchParams }: PageProps) {
  const cookieStore = cookies()
  const headersList = headers()
  const slug = params.slug
  const query = searchParams.q
  // ...
}

// After(Next.js 15)— すべてawaitが必要
import { cookies, headers } from 'next/headers'

export default async function Page({
  params,
  searchParams,
}: {
  params: Promise<{ slug: string }>
  searchParams: Promise<{ q: string }>
}) {
  const cookieStore = await cookies()
  const headersList = await headers()
  const { slug } = await params
  const { q } = await searchParams
  // ...
}

この変更(へんこう)はサーバーレンダリングの最適化(さいてきか)のためです。Next.jsはリクエストデータの実際(じっさい)の使用時点(じてん)まで遅延(ちえん)できるようになり、静的部分(ぶぶん)は事前(じぜん)にレンダリングし、動的部分(ぶぶん)のみリクエスト時(じ)に処理(しょり)します。

自動マイグレーションコードモッド:

npx @next/codemod@canary next-async-request-api .

2.3 PPR(Partial Pre-Rendering)

PPRは1つのページで静的(せいてき)部分(ぶぶん)と動的(どうてき)部分を同時(どうじ)に処理(しょり)する革新的(かくしんてき)なレンダリング戦略(せんりゃく)です:

// next.config.js
module.exports = {
  experimental: {
    ppr: 'incremental', // ルートごとに段階的に適用
  },
}
// app/product/[id]/page.tsx
import { Suspense } from 'react'

export const experimental_ppr = true // このルートでPPRを有効化

// 静的シェル:ビルド時に事前レンダリング
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const product = await getProduct(id) // ビルド時に取得

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <StaticImage src={product.image} />

      {/* 動的部分:リクエスト時にストリーミング */}
      <Suspense fallback={<PriceSkeleton />}>
        <DynamicPrice productId={id} />
      </Suspense>

      <Suspense fallback={<ReviewsSkeleton />}>
        <DynamicReviews productId={id} />
      </Suspense>
    </div>
  )
}

// 動的コンポーネント — cookies/headers使用で自動的にdynamic
async function DynamicPrice({ productId }: { productId: string }) {
  const cookieStore = await cookies()
  const region = cookieStore.get('region')?.value ?? 'US'
  const price = await getPrice(productId, region)
  return <PriceDisplay price={price} currency={region} />
}

PPRの動作原理(げんり):

  1. ビルド時(じ)に静的シェル(HTML)を生成(せいせい)
  2. 動的部分(ぶぶん)をSuspense境界(きょうかい)で表示(ひょうじ)し、フォールバックを含(ふく)める
  3. リクエストが来(き)たら静的シェルを即座(そくざ)に送信(そうしん)し、動的部分をストリーミング

2.4 next/after API

レスポンスを送信(そうしん)した後(あと)に非同期(ひどうき)作業(さぎょう)を実行(じっこう)できる新(あたら)しいAPIです:

import { after } from 'next/server'
import { log } from '@/lib/logger'

export default async function Page() {
  const data = await fetchData()

  // レスポンス後に実行 — ユーザーのレスポンス遅延なし
  after(() => {
    log('page-view', { data: data.id })
  })

  return <Dashboard data={data} />
}

ロギング、分析(ぶんせき)、キャッシュウォーミングなど、ユーザーのレスポンスに影響(えいきょう)を与(あた)えてはいけない作業(さぎょう)に理想的(りそうてき)です。

2.5 キャッシングデフォルトの変更

Next.js 15での最(もっと)も重要(じゅうよう)な哲学的(てつがくてき)変更(へんこう)です:

項目Next.js 14Next.js 15
fetchキャッシュforce-cache(デフォルトでキャッシュ)no-store(デフォルトでキャッシュなし)
GET Route Handlerキャッシュされるキャッシュされない
Client Router Cache5分0秒(ナビゲーション時常に最新)
// Next.js 15 — 明示的なキャッシュ設定を推奨
const data = await fetch('https://api.example.com/posts', {
  cache: 'force-cache', // 明示的にキャッシュを有効化
  next: { revalidate: 3600 }, // 1時間後に再検証
})

3. Server Components深掘り

3.1 RSCの動作原理

React Server ComponentsのコアはRSC Payload(別名(べつめい)Flight Protocol)です。サーバーで生成(せいせい)されたこのシリアライゼーション形式(けいしき)は、コンポーネントツリーをクライアントに伝達(でんたつ)します。

レンダリングフローは以下(いか)の通(とお)りです:

  1. サーバーでRSCツリーをレンダリングしてRSC Payloadを生成
  2. クライアントコンポーネント用(よう)の参照(さんしょう)(バンドルパス)を含(ふく)める
  3. クライアントでRSC Payloadを受(う)け取(と)りDOMツリーを構築(こうちく)
  4. クライアントコンポーネントのみハイドレーション実行(じっこう)
[Server]                    [Client]
   |                           |
   |-- RSC Payload (stream) -->|
   |   - シリアライズされた     |
   |     サーバーコンポーネント  |-- DOM構築
   |   - クライアント参照       |-- クライアントハイドレーション
   |   - Suspense境界          |
   |                           |

3.2 サーバー vs クライアントコンポーネントの境界

サーバーコンポーネントでできること:

  • DB直接(ちょくせつ)アクセス
  • ファイルシステムの読(よ)み取(と)り
  • サーバー専用(せんよう)ライブラリの使用(fs、crypto、ORMなど)
  • サーバー専用(せんよう)環境変数(かんきょうへんすう)へのアクセス
  • バンドルサイズへの影響(えいきょう)なし

クライアントコンポーネントが必要(ひつよう)な場合(ばあい):

  • useState、useEffectなどのReactフック
  • ブラウザAPI(localStorage、windowなど)
  • イベントリスナー(onClick、onChangeなど)
  • クラスコンポーネント
  • サードパーティライブラリ(ほとんどがクライアント専用)
// app/dashboard/page.tsx(Server Component — デフォルト)
import { db } from '@/lib/db'
import { DashboardChart } from './chart' // Client Component

export default async function DashboardPage() {
  // サーバーで直接DB接続 — APIレイヤー不要
  const metrics = await db.metrics.findMany({
    where: { date: { gte: thirtyDaysAgo() } },
    orderBy: { date: 'asc' },
  })

  // シリアライズ可能なデータのみクライアントコンポーネントに渡す
  return (
    <div>
      <h1>ダッシュボード</h1>
      <DashboardChart data={metrics} /> {/* Client Component */}
    </div>
  )
}
// app/dashboard/chart.tsx(Client Component)
'use client'

import { LineChart, Line, XAxis, YAxis } from 'recharts'

export function DashboardChart({ data }: { data: Metric[] }) {
  // クライアントでのみ実行されるインタラクティブチャート
  return (
    <LineChart width={800} height={400} data={data}>
      <XAxis dataKey="date" />
      <YAxis />
      <Line type="monotone" dataKey="value" stroke="#8884d8" />
    </LineChart>
  )
}

3.3 Compositionパターン:サーバーがクライアントを包む

サーバーコンポーネントがクライアントコンポーネントの親(おや)になるのがRSCのコアパターンです。逆(ぎゃく)に、クライアントコンポーネント内(ない)でサーバーコンポーネントをimportすると自動的(じどうてき)にクライアントになります。代(か)わりにchildrenパターンを使用(しよう)します:

// app/layout.tsx(Server Component)
import { Sidebar } from './sidebar' // Client Component
import { UserInfo } from './user-info' // Server Component

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex">
      {/* サーバーコンポーネントをクライアントコンポーネントのchildrenとして渡す */}
      <Sidebar>
        <UserInfo /> {/* これによりサーバーコンポーネントとして維持 */}
      </Sidebar>
      <main>{children}</main>
    </div>
  )
}
// app/sidebar.tsx(Client Component)
'use client'

import { useState } from 'react'

export function Sidebar({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(true)

  return (
    <aside className={isOpen ? 'w-64' : 'w-16'}>
      <button onClick={() => setIsOpen(!isOpen)}>トグル</button>
      {isOpen && children} {/* サーバーでレンダリングされたchildren */}
    </aside>
  )
}

3.4 DB直接アクセスとAPIレイヤーの削除

サーバーコンポーネントの最(もっと)も強力(きょうりょく)な利点(りてん)の一(ひと)つは、別途(べっと)のAPIエンドポイントなしでデータベースに直接(ちょくせつ)アクセスできることです:

// Before(Pages Router + API Routes)
// 1. pages/api/posts.ts — APIエンドポイント
// 2. lib/fetcher.ts — fetchユーティリティ
// 3. pages/blog.tsx — getServerSideProps + クライアントコンポーネント

// After(App Router + Server Components)
// 1つのファイルで十分
// app/blog/page.tsx
import { prisma } from '@/lib/prisma'
import { cache } from 'react'

// React cacheで同一リクエスト内の重複防止
const getPosts = cache(async () => {
  return prisma.post.findMany({
    include: { author: true, tags: true },
    orderBy: { createdAt: 'desc' },
    take: 20,
  })
})

export default async function BlogPage() {
  const posts = await getPosts()

  return (
    <div>
      <h1>ブログ</h1>
      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.author.name}</p>
          <time>{post.createdAt.toLocaleDateString()}</time>
        </article>
      ))}
    </div>
  )
}

3.5 バンドルサイズの最適化

サーバーコンポーネントで使用(しよう)するライブラリはクライアントバンドルに含(ふく)まれません:

// サーバーコンポーネント — これらのimportはクライアントバンドルに含まれない
import { marked } from 'marked' // 35KB
import hljs from 'highlight.js' // 180KB
import { parse } from 'yaml' // 25KB
import sanitizeHtml from 'sanitize-html' // 60KB

// 合計300KBのライブラリがクライアントに全く送信されない!
export default async function MarkdownPage({ slug }: { slug: string }) {
  const raw = await fs.readFile(`content/${slug}.md`, 'utf-8')
  const html = marked(raw, {
    highlight: (code, lang) => hljs.highlight(code, { language: lang }).value,
  })
  const safe = sanitizeHtml(html)

  return <div dangerouslySetInnerHTML={{ __html: safe }} />
}

4. Server Actions実践

Server Actionsは'use server'ディレクティブで定義(ていぎ)し、クライアントからサーバー関数(かんすう)を直接(ちょくせつ)呼(よ)び出(だ)すRPCパターンです。

4.1 フォーム処理:useActionState + Progressive Enhancement

// actions/auth.ts
'use server'

import { z } from 'zod'
import { redirect } from 'next/navigation'
import { createSession } from '@/lib/session'

const loginSchema = z.object({
  email: z.string().email('有効なメールアドレスを入力してください'),
  password: z.string().min(8, 'パスワードは8文字以上必要です'),
})

type LoginState = {
  message: string
  errors: Record<string, string[]>
}

export async function loginAction(prevState: LoginState, formData: FormData): Promise<LoginState> {
  const rawData = {
    email: formData.get('email'),
    password: formData.get('password'),
  }

  const validated = loginSchema.safeParse(rawData)

  if (!validated.success) {
    return {
      message: '',
      errors: validated.error.flatten().fieldErrors as Record<string, string[]>,
    }
  }

  const user = await authenticateUser(validated.data)

  if (!user) {
    return {
      message: 'メールアドレスまたはパスワードが正しくありません',
      errors: {},
    }
  }

  await createSession(user.id)
  redirect('/dashboard')
}
// app/login/page.tsx
'use client'

import { useActionState } from 'react'
import { loginAction } from '@/actions/auth'

export default function LoginPage() {
  const [state, formAction, isPending] = useActionState(loginAction, {
    message: '',
    errors: {},
  })

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="email">メールアドレス</label>
        <input id="email" name="email" type="email" required />
        {state.errors?.email && <p className="text-red-500">{state.errors.email[0]}</p>}
      </div>

      <div>
        <label htmlFor="password">パスワード</label>
        <input id="password" name="password" type="password" required />
        {state.errors?.password && <p className="text-red-500">{state.errors.password[0]}</p>}
      </div>

      {state.message && <p className="text-red-500">{state.message}</p>}

      <button type="submit" disabled={isPending}>
        {isPending ? 'ログイン中...' : 'ログイン'}
      </button>
    </form>
  )
}

このフォームはProgressive Enhancementをサポートします。JavaScriptが無効(むこう)な環境(かんきょう)でもHTMLフォーム自体(じたい)が動作(どうさ)します。

4.2 楽観的更新の実践

// app/comments/comment-section.tsx
'use client'

import { useOptimistic, useRef } from 'react'
import { addComment } from '@/actions/comments'

type Comment = {
  id: string
  text: string
  author: string
  createdAt: string
  pending?: boolean
}

export function CommentSection({
  postId,
  initialComments,
}: {
  postId: string
  initialComments: Comment[]
}) {
  const formRef = useRef<HTMLFormElement>(null)

  const [optimisticComments, addOptimisticComment] = useOptimistic(
    initialComments,
    (state, newComment: Comment) => [...state, newComment]
  )

  async function handleSubmit(formData: FormData) {
    const text = formData.get('text') as string

    // 即座にUI反映(楽観的)
    addOptimisticComment({
      id: `temp-${Date.now()}`,
      text,
      author: '自分',
      createdAt: new Date().toISOString(),
      pending: true,
    })

    formRef.current?.reset()

    // サーバーに実際に保存
    await addComment(postId, formData)
  }

  return (
    <div>
      <ul className="space-y-4">
        {optimisticComments.map((comment) => (
          <li key={comment.id} className={comment.pending ? 'opacity-50' : ''}>
            <strong>{comment.author}</strong>
            <p>{comment.text}</p>
            {comment.pending && <span>送信中...</span>}
          </li>
        ))}
      </ul>

      <form ref={formRef} action={handleSubmit}>
        <textarea name="text" placeholder="コメントを入力してください" required />
        <button type="submit">コメント投稿</button>
      </form>
    </div>
  )
}

4.3 ファイルアップロード

// actions/upload.ts
'use server'

import { writeFile } from 'fs/promises'
import { join } from 'path'
import { revalidatePath } from 'next/cache'

export async function uploadFile(formData: FormData) {
  const file = formData.get('file') as File

  if (!file || file.size === 0) {
    return { error: 'ファイルを選択してください' }
  }

  // ファイルサイズ制限(5MB)
  if (file.size > 5 * 1024 * 1024) {
    return { error: 'ファイルサイズは5MB以下にしてください' }
  }

  // 許可された型を確認
  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
  if (!allowedTypes.includes(file.type)) {
    return { error: 'JPEG、PNG、WebP画像のみアップロード可能です' }
  }

  const bytes = await file.arrayBuffer()
  const buffer = Buffer.from(bytes)

  const uniqueName = `${Date.now()}-${file.name}`
  const path = join(process.cwd(), 'public/uploads', uniqueName)

  await writeFile(path, buffer)
  revalidatePath('/gallery')

  return { success: true, path: `/uploads/${uniqueName}` }
}

4.4 revalidatePathとrevalidateTag

// actions/post.ts
'use server'

import { revalidatePath, revalidateTag } from 'next/cache'

export async function updatePost(id: string, formData: FormData) {
  await db.post.update({
    where: { id },
    data: {
      title: formData.get('title') as string,
      content: formData.get('content') as string,
    },
  })

  // 特定パスの再検証
  revalidatePath(`/blog/${id}`)

  // タグベースの再検証(より細かい制御)
  revalidateTag('posts')
  revalidateTag(`post-${id}`)
}

// データフェッチ時にタグを指定
async function getPost(id: string) {
  const res = await fetch(`https://api.example.com/posts/${id}`, {
    next: { tags: [`post-${id}`, 'posts'] },
  })
  return res.json()
}

4.5 Server Actions vs API Routesの比較

機能Server ActionsRoute Handlers(API Routes)
使用パターンフォーム送信、データ変更外部API、Webhook、サードパーティ連携
Progressive Enhancementサポート(JS無しで動作)非サポート
型安全性関数シグネチャで保証手動での型定義が必要
呼び出し方式form actionまたは直接呼び出しfetch/HTTPリクエスト
キャッシング自動revalidation統合手動キャッシュ設定
セキュリティ自動CSRF保護手動実装が必要
用途内部データ変更外部システム連携

5. App Router高度なパターン

5.1 Parallel Routes(@slot)

1つのレイアウトで複数(ふくすう)のページを同時(どうじ)にレンダリングします:

app/
  dashboard/
    @analytics/
      page.tsx      <- 分析スロット
      loading.tsx   <- 分析ローディング状態
    @team/
      page.tsx      <- チームスロット
      loading.tsx   <- チームローディング状態
    layout.tsx      <- 両スロットを組み合わせ
    page.tsx        <- メインコンテンツ
// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
}) {
  return (
    <div className="grid grid-cols-2 gap-4">
      <div className="col-span-2">{children}</div>
      <div>{analytics}</div>
      <div>{team}</div>
    </div>
  )
}

各(かく)スロットは独立(どくりつ)してロードされ、1つが遅(おそ)くても残(のこ)りは先(さき)に表示(ひょうじ)されます。

5.2 Intercepting Routes

モーダルパターンで特(とく)に有用(ゆうよう)です。URLを維持(いじ)しながら現在(げんざい)のレイアウト内(ない)で別(べつ)のルートを表示(ひょうじ)します:

app/
  feed/
    page.tsx           <- フィードリスト
    @modal/
      (..)photo/[id]/
        page.tsx       <- モーダルで写真表示
      default.tsx
    layout.tsx
  photo/[id]/
    page.tsx           <- フルページ写真表示(直接アクセス/リロード時)
// app/feed/@modal/(..)photo/[id]/page.tsx
import { Modal } from '@/components/modal'

export default async function PhotoModal({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const photo = await getPhoto(id)

  return (
    <Modal>
      <img src={photo.url} alt={photo.alt} />
      <p>{photo.description}</p>
    </Modal>
  )
}

フィードで写真(しゃしん)をクリックするとモーダルで開(ひら)きますが、URLは/photo/123に変更(へんこう)されます。このURLを直接(ちょくせつ)訪問(ほうもん)またはリロードすると、フルページバージョンがレンダリングされます。

5.3 Route Groups

URL構造(こうぞう)に影響(えいきょう)を与(あた)えずにルートを論理的(ろんりてき)にグループ化(か)します:

app/
  (marketing)/         <- URLに含まれない
    layout.tsx         <- マーケティング専用レイアウト
    page.tsx           <- / パス
    about/
      page.tsx         <- /about
    pricing/
      page.tsx         <- /pricing
  (dashboard)/         <- URLに含まれない
    layout.tsx         <- ダッシュボード専用レイアウト(サイドバーなど)
    dashboard/
      page.tsx         <- /dashboard
    settings/
      page.tsx         <- /settings

5.4 Loading、Error、Not-Foundバウンダリ

App Routerのファイルコンベンションは自動的(じどうてき)にSuspenseとError Boundaryを設定(せってい)します:

// app/blog/loading.tsx — Suspense fallbackが自動適用
export default function Loading() {
  return (
    <div className="animate-pulse space-y-4">
      <div className="h-8 w-3/4 rounded bg-gray-200" />
      <div className="h-4 w-full rounded bg-gray-200" />
      <div className="h-4 w-5/6 rounded bg-gray-200" />
    </div>
  )
}
// app/blog/error.tsx — Error Boundaryが自動適用
'use client' // Errorコンポーネントは必ずClient Component

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="py-10 text-center">
      <h2>問題が発生しました</h2>
      <p>{error.message}</p>
      <button onClick={reset} className="mt-4 bg-blue-500 px-4 py-2 text-white">
        もう一度試す
      </button>
    </div>
  )
}
// app/blog/[slug]/not-found.tsx
export default function NotFound() {
  return (
    <div className="py-20 text-center">
      <h2 className="text-2xl font-bold">記事が見つかりません</h2>
      <p className="mt-2 text-gray-600">リクエストされた記事は存在しません。</p>
    </div>
  )
}

5.5 generateMetadataで動的SEO

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

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

  if (!post) {
    return { title: 'Post Not Found' }
  }

  return {
    title: post.title,
    description: post.summary,
    openGraph: {
      title: post.title,
      description: post.summary,
      images: [post.coverImage],
      type: 'article',
      publishedTime: post.publishedAt,
      authors: [post.author.name],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.summary,
      images: [post.coverImage],
    },
  }
}

5.6 Middlewareの活用

// middleware.ts(プロジェクトルート)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  // 認証チェック
  const token = request.cookies.get('session-token')

  if (request.nextUrl.pathname.startsWith('/dashboard') && !token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  // 国際化 — Accept-Languageベースのリダイレクト
  const locale = request.headers.get('accept-language')?.split(',')[0]?.split('-')[0]
  if (request.nextUrl.pathname === '/' && locale === 'ko') {
    return NextResponse.rewrite(new URL('/ko', request.url))
  }

  // セキュリティヘッダーの追加
  const response = NextResponse.next()
  response.headers.set('X-Frame-Options', 'DENY')
  response.headers.set('X-Content-Type-Options', 'nosniff')

  return response
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}

6. キャッシング戦略の完全攻略

Next.jsのキャッシングは4つのレイヤーで構成(こうせい)されており、それぞれの役割(やくわり)を理解(りかい)することが重要(じゅうよう)です。

6.1 4つのキャッシングレイヤー

1. Request Memoization(リクエストメモ化)

同(おな)じレンダリングサイクル内(ない)で同一(どういつ)のfetchリクエストを自動(じどう)的に重複排除(じゅうふくはいじょ)します:

// 同一レンダリング内の2つのコンポーネントで同じ呼び出し -> 実際のリクエストは1回のみ
async function Header() {
  const user = await fetch('/api/user') // リクエスト1
  return <h1>{user.name}</h1>
}

async function Sidebar() {
  const user = await fetch('/api/user') // メモ化 — 実際のリクエストなし
  return <nav>{user.role}</nav>
}

2. Data Cache(データキャッシュ)

fetchレスポンスをサーバーに永続的(えいぞくてき)に保存(ほぞん)します(Next.js 15ではデフォルト無効(むこう)):

// 明示的なキャッシュ設定
const data = await fetch('https://api.example.com/data', {
  cache: 'force-cache', // 永続キャッシュ
  next: { revalidate: 3600 }, // 1時間後に再検証
  next: { tags: ['products'] }, // タグベースの再検証
})

3. Full Route Cache(フルルートキャッシュ)

ビルド時(じ)に生成(せいせい)された静的ルートのHTMLとRSC Payloadをキャッシュします:

// このページはビルド時にキャッシュされる(静的ルート)
export default async function AboutPage() {
  return <div>会社概要</div>
}

// generateStaticParamsで動的パスも静的生成
export async function generateStaticParams() {
  const posts = await getPosts()
  return posts.map((post) => ({ slug: post.slug }))
}

4. Router Cache(ルーターキャッシュ)

クライアントで訪問(ほうもん)したルートのRSC Payloadをメモリにキャッシュします。Next.js 15ではデフォルト0秒(びょう)(常(つね)に最新データ):

// next.config.jsでRouter Cacheの設定が可能
module.exports = {
  experimental: {
    staleTimes: {
      dynamic: 30, // 動的ルート:30秒
      static: 180, // 静的ルート:180秒
    },
  },
}

6.2 cacheLifeとcacheTag(Next.js 15)

以前(いぜん)のunstable_cacheを置(お)き換(か)える新(あたら)しいキャッシングAPIです:

// next.config.js
module.exports = {
  experimental: {
    dynamicIO: true,
    cacheLife: {
      blog: {
        stale: 300, // 5分間キャッシュを使用
        revalidate: 900, // 15分ごとにバックグラウンド再検証
        expire: 86400, // 24時間後に期限切れ
      },
      frequent: {
        stale: 0,
        revalidate: 60,
        expire: 300,
      },
    },
  },
}
import { cacheLife, cacheTag } from 'next/cache'

async function getProducts() {
  'use cache'
  cacheLife('blog')
  cacheTag('products')

  return db.product.findMany()
}

6.3 レンダリング戦略の比較表

戦略ビルド時リクエスト時再検証ユースケース
SSG(Static)HTML生成キャッシュから提供リビルド時ブログ、ドキュメント
ISRHTML生成キャッシュ + バックグラウンド更新時間/タグベース商品リスト、ニュース
SSR毎リクエストレンダリングなしパーソナライズページ
PPR静的シェル生成動的部分のみレンダリング混合商品詳細(価格+説明)

6.4 実践:ECサイト商品ページのキャッシング戦略

// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { unstable_cache } from 'next/cache'

// 商品基本情報:あまり変わらないのでISR(1時間)
const getProductInfo = unstable_cache(
  async (id: string) => {
    return db.product.findUnique({
      where: { id },
      select: { name: true, description: true, images: true },
    })
  },
  ['product-info'],
  { revalidate: 3600, tags: ['products'] }
)

// 価格/在庫:頻繁に変わるので短いキャッシュ(1分)
const getProductPricing = unstable_cache(
  async (id: string) => {
    return db.product.findUnique({
      where: { id },
      select: { price: true, stock: true, discount: true },
    })
  },
  ['product-pricing'],
  { revalidate: 60, tags: ['pricing'] }
)

// レビュー:ユーザー別データを含むため動的
async function getProductReviews(id: string) {
  return db.review.findMany({
    where: { productId: id },
    orderBy: { createdAt: 'desc' },
    take: 10,
  })
}

export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const product = await getProductInfo(id)

  return (
    <div>
      <h1>{product?.name}</h1>
      <p>{product?.description}</p>

      <Suspense fallback={<div>価格ロード中...</div>}>
        <PriceSection productId={id} />
      </Suspense>

      <Suspense fallback={<div>レビューロード中...</div>}>
        <ReviewSection productId={id} />
      </Suspense>
    </div>
  )
}

async function PriceSection({ productId }: { productId: string }) {
  const pricing = await getProductPricing(productId)
  return (
    <div>
      <span className="text-2xl font-bold">{pricing?.price}</span>
      {pricing?.discount && <span className="text-red-500">{pricing.discount}%オフ</span>}
      <p>{pricing?.stock ? `在庫:${pricing.stock}` : '在庫切れ'}</p>
    </div>
  )
}

async function ReviewSection({ productId }: { productId: string }) {
  const reviews = await getProductReviews(productId)
  return (
    <div>
      <h2>レビュー({reviews.length}</h2>
      {reviews.map((review) => (
        <div key={review.id}>
          <strong>{review.author}</strong>
          <p>{review.content}</p>
        </div>
      ))}
    </div>
  )
}

7. パフォーマンス最適化

7.1 Turbopack vs Webpackベンチマーク

Next.js 15での実際(じっさい)のプロジェクト基準(きじゅん)の比較(ひかく):

測定項目WebpackTurbopack改善率
Cold Start(dev)8.2秒1.9秒76.7%
Hot Module Replacement520ms19ms96.3%
ルートコンパイル(初回アクセス)1.8秒0.97秒45.8%
メモリ使用量(2000+モジュール)1.2GB650MB45.8%

7.2 next/image、next/font、next/script

// Image最適化
import Image from 'next/image'

function ProductCard({ product }: { product: Product }) {
  return (
    <Image
      src={product.image}
      alt={product.name}
      width={400}
      height={300}
      placeholder="blur"
      blurDataURL={product.blurHash}
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
      priority={false} // スクロール下の画像はlazy load
    />
  )
}
// Font最適化 — レイアウトシフト防止
import { Inter, Noto_Sans_JP } from 'next/font/google'

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

const notoSansJP = Noto_Sans_JP({
  subsets: ['latin'],
  weight: ['400', '700'],
  display: 'swap',
  variable: '--font-noto',
})

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja" className={`${inter.variable} ${notoSansJP.variable}`}>
      <body>{children}</body>
    </html>
  )
}
// Script最適化
import Script from 'next/script'

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      {children}
      {/* afterInteractive:ページハイドレーション後にロード(デフォルト) */}
      <Script src="https://analytics.example.com/script.js" strategy="afterInteractive" />
      {/* lazyOnload:ブラウザアイドル時にロード */}
      <Script src="https://chatbot.example.com/widget.js" strategy="lazyOnload" />
      {/* worker:Web Workerで実行(実験的) */}
      <Script src="https://heavy-lib.example.com/main.js" strategy="worker" />
    </>
  )
}

7.3 Bundle Analyzer + Tree Shaking

npm install @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})

module.exports = withBundleAnalyzer({
  // 既存の設定
})
ANALYZE=true npm run build

Tree Shaking最適化(さいてきか)のヒント:

// Bad — ライブラリ全体をimport
import _ from 'lodash'
const sorted = _.sortBy(items, 'name')

// Good — 必要な関数のみimport
import sortBy from 'lodash/sortBy'
const sorted = sortBy(items, 'name')

// Best — Server Componentsで使用(バンドルに含まれない)
// Server Componentなら全体importでも問題なし
import _ from 'lodash'

7.4 Dynamic ImportsとLazy Loading

import dynamic from 'next/dynamic'

// クライアント専用コンポーネントのlazy load
const DynamicChart = dynamic(() => import('@/components/chart'), {
  loading: () => <ChartSkeleton />,
  ssr: false, // サーバーレンダリングをスキップ
})

// 条件付きロード
const DynamicModal = dynamic(() => import('@/components/modal'))

export default function Page() {
  const [showModal, setShowModal] = useState(false)

  return (
    <div>
      <DynamicChart data={data} />
      <button onClick={() => setShowModal(true)}>モーダルを開く</button>
      {showModal && <DynamicModal onClose={() => setShowModal(false)} />}
    </div>
  )
}

7.5 Core Web Vitals最適化

LCP(Largest Contentful Paint)最適化:

// 最大コンテンツ要素にpriorityを設定
<Image src="/hero.jpg" alt="Hero" priority sizes="100vw" />

// 重要リソースのプリロード
<link rel="preload" href="/api/critical-data" as="fetch" crossOrigin="anonymous" />

CLS(Cumulative Layout Shift)最適化:

// 画像サイズを明示(width/height)
<Image src="/photo.jpg" width={800} height={600} alt="Photo" />

// フォントswapでFOIT防止
const font = Inter({ display: 'swap' })

// 動的コンテンツに最小高さを設定
<div style={{ minHeight: '200px' }}>
  <Suspense fallback={<Skeleton height={200} />}>
    <DynamicContent />
  </Suspense>
</div>

INP(Interaction to Next Paint)最適化:

// 重い処理はuseTransitionで優先度を下げる
'use client'
import { useTransition } from 'react'

function SearchResults() {
  const [isPending, startTransition] = useTransition()
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])

  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value) // 即座に反映(高優先度)

    startTransition(() => {
      // 結果フィルタリングは低優先度
      setResults(filterLargeDataset(e.target.value))
    })
  }

  return (
    <div>
      <input value={query} onChange={handleSearch} />
      {isPending ? <Spinner /> : <ResultList results={results} />}
    </div>
  )
}

8. 実践プロジェクト:フルスタックダッシュボード

8.1 プロジェクト構造

my-dashboard/
  app/
    (auth)/
      login/page.tsx
      register/page.tsx
      layout.tsx
    (dashboard)/
      dashboard/
        page.tsx
        loading.tsx
        error.tsx
      settings/
        page.tsx
      layout.tsx
    api/
      webhooks/
        route.ts
    layout.tsx
  actions/
    auth.ts
    dashboard.ts
  lib/
    prisma.ts
    auth.ts
    utils.ts
  components/
    ui/
    charts/
    forms/
  prisma/
    schema.prisma

8.2 認証:Auth.js v5(NextAuth.js v5)

// lib/auth.ts
import NextAuth from 'next-auth'
import Google from 'next-auth/providers/google'
import Credentials from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from './prisma'
import bcrypt from 'bcryptjs'

export const { handlers, signIn, signOut, auth } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    Google,
    Credentials({
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      authorize: async (credentials) => {
        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
        })
        if (!user || !user.password) return null

        const valid = await bcrypt.compare(credentials.password as string, user.password)
        return valid ? user : null
      },
    }),
  ],
  callbacks: {
    authorized: async ({ auth: session }) => {
      return !!session
    },
    session: ({ session, token }) => ({
      ...session,
      user: { ...session.user, id: token.sub },
    }),
  },
  pages: {
    signIn: '/login',
  },
})
// middleware.ts
export { auth as middleware } from '@/lib/auth'

export const config = {
  matcher: ['/dashboard/:path*', '/settings/:path*'],
}
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/lib/auth'
export const { GET, POST } = handlers

8.3 データベース:Prisma + PostgreSQL

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  password  String?
  image     String?
  accounts  Account[]
  sessions  Session[]
  dashboards Dashboard[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Dashboard {
  id        String   @id @default(cuid())
  name      String
  userId    String
  user      User     @relation(fields: [userId], references: [id])
  widgets   Widget[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Widget {
  id          String    @id @default(cuid())
  type        String
  title       String
  config      Json
  dashboardId String
  dashboard   Dashboard @relation(fields: [dashboardId], references: [id])
  position    Int
}

8.4 状態管理:TanStack Query + Server Components

// app/(dashboard)/dashboard/page.tsx(Server Component)
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { DashboardClient } from './dashboard-client'

export default async function DashboardPage() {
  const session = await auth()
  if (!session?.user?.id) return null

  // サーバーで初期データをfetch
  const dashboard = await prisma.dashboard.findFirst({
    where: { userId: session.user.id },
    include: { widgets: { orderBy: { position: 'asc' } } },
  })

  // 初期データをClient Componentに渡す
  return <DashboardClient initialData={dashboard} />
}
// app/(dashboard)/dashboard/dashboard-client.tsx
'use client'

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { updateWidget } from '@/actions/dashboard'

export function DashboardClient({ initialData }: { initialData: Dashboard | null }) {
  const queryClient = useQueryClient()

  // サーバーデータを初期値として、以降はクライアントで更新
  const { data: dashboard } = useQuery({
    queryKey: ['dashboard'],
    queryFn: () => fetch('/api/dashboard').then((r) => r.json()),
    initialData,
    staleTime: 60_000, // 1分
  })

  const mutation = useMutation({
    mutationFn: updateWidget,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['dashboard'] })
    },
  })

  return (
    <div className="grid grid-cols-3 gap-4">
      {dashboard?.widgets.map((widget) => (
        <WidgetCard key={widget.id} widget={widget} onUpdate={(data) => mutation.mutate(data)} />
      ))}
    </div>
  )
}

8.5 デプロイ:Vercel Edge Runtime

// app/api/fast-api/route.ts
export const runtime = 'edge' // Edge Runtimeを使用

export async function GET(request: Request) {
  const url = new URL(request.url)
  const query = url.searchParams.get('q')

  // 最寄りのリージョンで実行 — 低レイテンシ
  const data = await fetch(`https://api.example.com/search?q=${query}`, {
    next: { revalidate: 60 },
  })

  return Response.json(await data.json())
}
{
  "regions": ["icn1", "nrt1"],
  "functions": {
    "app/api/**": {
      "memory": 1024,
      "maxDuration": 30
    }
  }
}

9. Pages RouterからApp Routerへのマイグレーション

9.1 段階的マイグレーション戦略

App RouterとPages Routerは**共存(きょうぞん)可能(かのう)**です。これを活用(かつよう)した段階的(だんかいてき)マイグレーション戦略:

Phase 1:レイアウトのマイグレーション
  app/layout.tsxを作成(ルートレイアウト)
  pages/_app.tsxのロジックをapp/layout.tsxへ

Phase 2:静的ページから移動
  pages/about.tsx -> app/about/page.tsx
  pages/pricing.tsx -> app/pricing/page.tsx

Phase 3:動的ルートのマイグレーション
  pages/blog/[slug].tsx -> app/blog/[slug]/page.tsx
  getServerSideProps -> async Server Component

Phase 4API Routesの整理
  内部呼び出し -> Server Actionsに置き換え
  外部連携 -> app/api/route.ts(Route Handlers)

Phase 5:最適化
  キャッシング戦略の設定
  Parallel Routes、Intercepting Routesの適用

9.2 getServerSidePropsからServer Componentsへ

// Before:pages/blog/[slug].tsx
export async function getServerSideProps({ params }) {
  const post = await prisma.post.findUnique({
    where: { slug: params.slug },
  })

  if (!post) {
    return { notFound: true }
  }

  return {
    props: { post: JSON.parse(JSON.stringify(post)) },
  }
}

export default function BlogPost({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  )
}
// After:app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { prisma } from '@/lib/prisma'

export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await prisma.post.findUnique({
    where: { slug },
  })

  if (!post) {
    notFound() // 自動的にnot-found.tsxをレンダリング
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  )
}

9.3 getStaticPropsからgenerateStaticParamsへ

// Before:pages/products/[id].tsx
export async function getStaticPaths() {
  const products = await prisma.product.findMany({ select: { id: true } })
  return {
    paths: products.map((p) => ({ params: { id: p.id } })),
    fallback: 'blocking',
  }
}

export async function getStaticProps({ params }) {
  const product = await prisma.product.findUnique({
    where: { id: params.id },
  })
  return {
    props: { product: JSON.parse(JSON.stringify(product)) },
    revalidate: 3600,
  }
}
// After:app/products/[id]/page.tsx
import { prisma } from '@/lib/prisma'

export async function generateStaticParams() {
  const products = await prisma.product.findMany({ select: { id: true } })
  return products.map((p) => ({ id: p.id }))
}

// dynamicParams = trueがデフォルト(fallback: 'blocking'と同等)
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const product = await prisma.product.findUnique({ where: { id } })

  return (
    <div>
      <h1>{product?.name}</h1>
      <p>{product?.description}</p>
    </div>
  )
}

9.4 API RoutesからRoute HandlersとServer Actionsへ

// Before:pages/api/posts.ts
export default async function handler(req, res) {
  if (req.method === 'GET') {
    const posts = await prisma.post.findMany()
    return res.json(posts)
  }

  if (req.method === 'POST') {
    const post = await prisma.post.create({ data: req.body })
    return res.status(201).json(post)
  }
}
// After(外部連携用):app/api/posts/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'

export async function GET() {
  const posts = await prisma.post.findMany()
  return NextResponse.json(posts)
}

// 内部データ変更はServer Actionで代替
// actions/posts.ts
;('use server')

import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  const post = await prisma.post.create({
    data: {
      title: formData.get('title') as string,
      content: formData.get('content') as string,
    },
  })

  revalidatePath('/blog')
  return post
}

9.5 注意点と落とし穴

1. Client Componentsにシリアライズ不可能なデータを渡さない:

// エラー:Dateオブジェクトはシリアライズ不可
<ClientComponent date={new Date()} />

// 修正:文字列に変換
<ClientComponent date={new Date().toISOString()} />

2. Server ComponentsでContextは使用不可:

// エラー:Server ComponentsでuseContextは使用不可
// 修正:Client ComponentにProviderを分離

// app/providers.tsx
'use client'
export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </ThemeProvider>
  )
}

// app/layout.tsx(Server Component)
import { Providers } from './providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

3. 環境変数へのアクセス:

// NEXT_PUBLIC_プレフィックスがない環境変数はサーバーでのみアクセス可能
// Server Componentsでは自由に使用
const dbUrl = process.env.DATABASE_URL // サーバー専用

// Client ComponentsではNEXT_PUBLIC_プレフィックスが必要
const apiUrl = process.env.NEXT_PUBLIC_API_URL // クライアントでもアクセス可能

4. サードパーティライブラリの互換性:

多(おお)くのライブラリがまだServer Componentsをサポートしていません。対処法(たいしょほう):

// ラッパーClient Componentを作成
// components/client-only-lib.tsx
'use client'

import { SomeClientLib } from 'some-client-lib'

export function ClientOnlyWrapper(props: any) {
  return <SomeClientLib {...props} />
}

// Server Componentで使用
import { ClientOnlyWrapper } from '@/components/client-only-lib'

export default function Page() {
  return <ClientOnlyWrapper data={serverData} />
}

10. クイズ

Q1. React 19でuseMemo、useCallback、memoを置き換える機能は何ですか?

React Compiler(以前の名称React Forget)です。ビルド時にコンポーネントを分析して自動的にメモ化を適用します。開発者が手動でメモ化フックを書く必要がなくなり、コードが簡潔になりミスを減らせます。

Next.js 15では、next.config.jsexperimental.reactCompiler: trueで有効化できます。

Q2. Next.js 15でparams、searchParams、cookies()、headers()がすべて非同期に変更された理由は何ですか?

**PPR(Partial Pre-Rendering)**の最適化のためです。リクエストデータのアクセスを実際の使用時点まで遅延できるようになり、静的部分はビルド時に事前レンダリングし、動的部分(リクエストデータに依存する部分)のみランタイムで処理できます。これにより、1つのページで静的シェルを即座に提供し、動的コンテンツをストリーミングすることが可能になりました。

マイグレーションにはnpx @next/codemod@canary next-async-request-api .コードモッドを使用できます。

Q3. Client Component内でServer Componentの子要素をServer Componentのまま維持するパターンは何ですか?

Composition(合成)パターンまたはchildrenパターンです。

Server ComponentがClient Componentをレンダリングしながら、サーバーで事前レンダリングされたコンテンツをchildren propとして渡します。Client Componentはchildrenをそのままレンダリングするだけなので、渡されたServer Componentsはサーバーでレンダリングされた状態を維持します。

注意:Client Component内でServer Componentを直接importすると、そのServer Componentは自動的にClient Componentに変換されます。

Q4. Next.js 15の4つのキャッシングレイヤーを説明し、それぞれの位置(サーバー/クライアント)を区別してください。
  1. Request Memoization(サーバー):同じレンダリングサイクル内で同一のfetchリクエストを自動的に重複排除します。Reactの機能です。

  2. Data Cache(サーバー):fetchレスポンスをサーバーに永続的に保存します。Next.js 15ではデフォルト無効(no-store)で、明示的に有効化する必要があります。

  3. Full Route Cache(サーバー):ビルド時に生成された静的ルートのHTMLとRSC Payloadを保存します。SSG/ISRルートに適用されます。

  4. Router Cache(クライアント):訪問したルートのRSC Payloadをブラウザメモリにキャッシュします。Next.js 15ではデフォルト0秒(常にサーバーから最新データを取得)です。

Q5. Server ActionsとRoute Handlers(API Routes)は、それぞれどのような状況で使用するのが適切ですか?

Server Actions:

  • フォーム送信とデータ変更(CRUD)操作
  • Progressive Enhancementが必要な場合(JS無効環境での対応)
  • 型安全なサーバー関数呼び出しが必要な場合
  • revalidatePath/revalidateTagとの自然な統合
  • 内部データ変更(DB更新、ファイル保存など)

Route Handlers:

  • 外部システムとの連携(Webhook受信、OAuthコールバックなど)
  • サードパーティサービスが呼び出すAPIエンドポイント
  • ファイルダウンロード、画像生成などカスタムレスポンスが必要な場合
  • REST APIを外部に公開する必要がある場合

一般的な原則:内部データ変更にはServer Actions、外部システム連携にはRoute Handlersを使用します。


11. 参考資料

  1. React 19公式ブログ — React v19リリースノート
  2. Next.js 15公式ブログ — Next.js 15リリースノート
  3. Next.js公式ドキュメント — App Router
  4. Next.js公式ドキュメント — Server Components
  5. Next.js公式ドキュメント — Server Actions and Mutations
  6. Next.js公式ドキュメント — Caching
  7. React公式ドキュメント — use() Hook
  8. React公式ドキュメント — React Compiler
  9. Vercelブログ — Partial Pre-Rendering
  10. Next.js公式ドキュメント — Turbopack
  11. Auth.js(NextAuth.js v5)公式ドキュメント
  12. Prisma公式ドキュメント — Next.js統合
  13. TanStack Query公式ドキュメント — Next.js Integration
  14. Next.js公式ドキュメント — Pages Routerからの移行
  15. Web.dev — Core Web Vitals
  16. Vercelブログ — How React Server Components Work
  17. Next.js GitHub — next/after RFC
  18. Next.js公式ドキュメント — Middleware