Next.jsReactTypeScriptサーバーコンポーネントApp Router

Next.js 15 App Routerのサーバーコンポーネント完全ガイド

読了約 9

Next.js 13でApp Routerが導入されて以来、React Server Components(RSC)の概念は多くの開発者を混乱させてきました。Next.js 15ではさらにキャッシュの動作が変更され、理解すべき概念が増えています。この記事では、サーバーコンポーネントとクライアントコンポーネントの本質的な違いから、実践的なデータフェッチパターン、よくある間違いとその解決法まで、体系的に解説します。

サーバーコンポーネントとクライアントコンポーネントの違い

App Routerでは、デフォルトですべてのコンポーネントがサーバーコンポーネントです。クライアントコンポーネントにするには、ファイルの先頭に"use client"ディレクティブを追加します。この違いは実行環境の違いであり、それぞれに明確な得意・不得意があります。

  • サーバーコンポーネント:サーバーで実行、データベースに直接アクセス可能、JavaScriptバンドルに含まれない、useStateやuseEffectは使えない
  • クライアントコンポーネント:ブラウザで実行、インタラクティブな機能(useState、useEffect、onClick等)が使える、バンドルサイズを増加させる

ℹ️ 情報

重要な誤解:クライアントコンポーネントもサーバーサイドでのHTML生成(SSR)は行われます。"クライアント"とはハイドレーション後のインタラクティブな実行環境を指します。

データフェッチのパターン

Next.js 15では、fetch APIがデフォルトでキャッシュされなくなりました(Next.js 13〜14ではデフォルトでキャッシュされていた)。この変更により、明示的なキャッシュ設定が必要になっています。

typescript
// app/products/page.tsx
// サーバーコンポーネント(デフォルト)

interface Product {
  id: number
  title: string
  price: number
}

async function getProducts(): Promise<Product[]> {
  // Next.js 15: デフォルトでno-store(キャッシュなし)
  const res = await fetch('https://api.example.com/products')

  if (!res.ok) {
    throw new Error('Failed to fetch products')
  }

  return res.json()
}

async function getCachedProducts(): Promise<Product[]> {
  // 1時間キャッシュ
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 3600 }
  })

  if (!res.ok) {
    throw new Error('Failed to fetch products')
  }

  return res.json()
}

async function getStaticProducts(): Promise<Product[]> {
  // ビルド時のみ取得(ISRなし)
  const res = await fetch('https://api.example.com/products', {
    cache: 'force-cache'
  })

  return res.json()
}

export default async function ProductsPage() {
  const products = await getProducts()

  return (
    <main>
      <h1>製品一覧</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            {product.title} - ¥{product.price.toLocaleString()}
          </li>
        ))}
      </ul>
    </main>
  )
}

データベースへの直接アクセス

サーバーコンポーネントの最大の利点は、APIを経由せずにデータベースに直接アクセスできることです。PrismaやDrizzle ORMを使えば、型安全なクエリが書けます。

typescript
// app/dashboard/page.tsx
import { db } from '@/lib/db' // Prisma Client

export default async function DashboardPage() {
  // APIルートを経由せず直接DBクエリ
  const users = await db.user.findMany({
    select: {
      id: true,
      name: true,
      email: true,
      createdAt: true,
    },
    orderBy: { createdAt: 'desc' },
    take: 10,
  })

  return (
    <div>
      <h1>ダッシュボード</h1>
      <table>
        <thead>
          <tr>
            <th>名前</th>
            <th>メール</th>
            <th>登録日</th>
          </tr>
        </thead>
        <tbody>
          {users.map((user) => (
            <tr key={user.id}>
              <td>{user.name}</td>
              <td>{user.email}</td>
              <td>{user.createdAt.toLocaleDateString('ja-JP')}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

"use client"の正しい使い方

"use client"は最小限のコンポーネントにのみ適用するのがベストプラクティスです。インタラクティブな部分だけを切り出し、親コンポーネントはサーバーコンポーネントのままにすることで、バンドルサイズを最小化できます。

typescript
// components/ProductCard.tsx
// サーバーコンポーネント("use client"なし)
import { AddToCartButton } from './AddToCartButton'

interface Product {
  id: number
  title: string
  price: number
}

interface ProductCardProps {
  product: Product
}

export function ProductCard({ product }: ProductCardProps) {
  return (
    <div className="border rounded-lg p-4">
      <h2>{product.title}</h2>
      <p>¥{product.price.toLocaleString()}</p>
      {/* クライアントコンポーネントを子として埋め込み */}
      <AddToCartButton productId={product.id} />
    </div>
  )
}

// components/AddToCartButton.tsx
'use client'

import { useState } from 'react'

interface AddToCartButtonProps {
  productId: number
}

export function AddToCartButton({ productId }: AddToCartButtonProps) {
  const [added, setAdded] = useState(false)

  return (
    <button
      onClick={() => setAdded(true)}
      className="bg-blue-600 text-white px-4 py-2 rounded"
    >
      {added ? 'カートに追加済み' : 'カートに追加'}
    </button>
  )
}

よくある間違いと解決法

App Routerに移行する際、多くの開発者が同じ間違いを犯します。以下に最もよく見られる問題とその解決法をまとめます。

  • 間違い:クライアントコンポーネント内でasync/awaitを直接使う → 解決:別のサーバーコンポーネントでデータを取得し、propsで渡す
  • 間違い:サーバーコンポーネントでクライアント専用ライブラリ(localStorage、window等)を使う → 解決:"use client"を追加するか、動的インポートでSSRを無効化する
  • 間違い:fetch()のキャッシュ設定を忘れる → 解決:明示的にrevalidateかcacheオプションを設定する
  • 間違い:サーバーコンポーネントにイベントハンドラーを付ける → 解決:そのロジックをクライアントコンポーネントに移動する

パフォーマンス最適化

サーバーコンポーネントを使いこなすことで、大幅なパフォーマンス改善が期待できます。特にLCP(Largest Contentful Paint)の改善に効果的です。並列データフェッチ、Suspenseによる段階的レンダリング、静的生成と動的レンダリングの使い分けが重要なポイントです。

typescript
// app/dashboard/page.tsx
// 並列データフェッチの例
import { Suspense } from 'react'
import { UserStats } from './UserStats'
import { RecentOrders } from './RecentOrders'
import { RevenueChart } from './RevenueChart'

export default function DashboardPage() {
  return (
    <div className="grid grid-cols-3 gap-6">
      {/* 各コンポーネントが独立してデータをフェッチ */}
      <Suspense fallback={<div>統計を読み込み中...</div>}>
        <UserStats />
      </Suspense>

      <Suspense fallback={<div>注文を読み込み中...</div>}>
        <RecentOrders />
      </Suspense>

      <Suspense fallback={<div>グラフを読み込み中...</div>}>
        <RevenueChart />
      </Suspense>
    </div>
  )
}

// app/dashboard/UserStats.tsx
async function getUserCount(): Promise<number> {
  const res = await fetch('/api/users/count', {
    next: { revalidate: 60 }
  })
  const data = await res.json() as { count: number }
  return data.count
}

export async function UserStats() {
  const count = await getUserCount()
  return <div>ユーザー数: {count}</div>
}

💡 ヒント

Suspenseを使うことで、遅いデータソースが他のコンポーネントのレンダリングをブロックしなくなります。ウォーターフォールを避け、並列フェッチを積極的に活用しましょう。

Next.jsReactTypeScriptサーバーコンポーネントApp Router

AI協働開発のご相談はこちら

この記事の内容を実際のプロジェクトに活用したい方、 開発のご依頼・ご質問はお気軽にどうぞ。

無料相談を申し込む