Next.js Server Components データ取得完全ガイド:fetch、DB直接取得、ベストプラクティス
Next.js App Router で初めてコンポーネントを書いた時、私はエディタの前でしばらくフリーズしました。
async function Page() {
const data = await fetch('...')
return <div>{data}</div>
}
「えっ、これだけ? いきなり await?」
useEffect や useState を使わず、競合状態(Race Conditions)も気にしなくていいの?
正直、最初は戸惑いました。React のクライアントサイドの流儀に慣れすぎていて、「コンポーネントが非同期関数になる」というのが、まるでルール違反のように感じられたのです。さらに悩ましいのが、「結局 fetch API を使うべきなのか、それとも直接データベースを叩くべきなのか?」という問題。fetch だと API 呼び出しが1層増えるし、DB 直接だとクライアントにキーが漏れないか不安になる…。
もしあなたも同じような疑問を持っているなら、この記事が答えになります。Next.js Server Components でのデータ取得の正解——いつ fetch を使い、いつ DB を叩くのか、async コンポーネントの正しい書き方、キャッシュ制御、そして避けるべき落とし穴について解説します。
Server Components データ取得の基礎
なぜ Server Components は直接 await できるのか?
答えはシンプルです:これらはブラウザではなく、サーバー上で実行されるからです。
当たり前に聞こえるかもしれませんが、この違いが決定てきです。従来の React コンポーネントはブラウザでレンダリングされるため、データベースやファイルシステムに直接アクセスできませんでした。しかし Server Components はサーバー上で実行されるため、以前は API ルートでしかできなかったことが可能になります:
- データベースへの直接接続(Prisma, Drizzle, 生SQLなど)
- ファイルシステムの読み込み(Markdown ファイルなど)
- 内部サービスの呼び出し(CORS の心配なし)
- 環境変数や秘密鍵へのアクセス(クライアントに漏洩しない)
したがって、以下のコードは完全に安全です:
// app/posts/page.tsx
import { db } from '@/lib/db'
async function PostsPage() {
// DB を直接叩く。秘密鍵はブラウザには送信されない
const posts = await db.post.findMany()
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
export default PostsPage
ポイント:
- コンポーネントを
async functionとして宣言する。 - データベースクエリを直接
awaitできる。 - React Hooks(
useState,useEffect)は使えない。 - デフォルトではサーバーでレンダリングされ、クライアントには生成された HTML だけが届く。
3つの主要なデータ取得方法
Server Components では、主に3つの選択肢があります:
1. fetch API
外部 API や独自の Route Handler を呼び出す最も馴染みのある方法:
async function Page() {
const res = await fetch('https://api.example.com/data')
const data = await res.json()
return <div>{data.title}</div>
}
2. データベース直接クエリ
ORM や DB クライアントを使って直接取得:
import { db } from '@/lib/db'
async function Page() {
const data = await db.posts.findFirst()
return <div>{data.title}</div>
}
3. Server Actions
主にデータの変更(フォーム送信、削除など)に使われますが、データの取得も可能です:
async function createPost(formData: FormData) {
'use server'
const title = formData.get('title')
await db.post.create({ data: { title } })
}
では、どれを使うべきなのでしょうか?
fetch vs データベースクエリ —— 選択の基準
これは私が App Router を使い始めた時に最も悩んだ点です。結論から言うと、判断基準は明確です。
5秒で決まるデシジョンツリー
以下の3つの質問に答えてください:
- これは Server Component ですか? → YES なら次へ。NO(Client Component)なら質問3へ。
- データはどこにありますか?
- 自分のデータベース → データベース直接クエリ
- 外部 API → fetch API
- Client Component からデータを取得する必要がありますか? → Route Handler (API) を作って、そこから fetch する
シンプルですね。
なぜ「直接クエリ」を優先するのか?
Next.js 公式も推奨していますが、Server Component 内では API ルートを経由せず、直接 DB を叩くべきです。
理由は合理的です:
1. HTTP ラウンドトリップの節約
比較してみましょう:
// ❌ 遠回り:Server Component → API Route → Database
async function Page() {
const res = await fetch('http://localhost:3000/api/posts') // 内部的な HTTP 通信が発生
const posts = await res.json()
return <PostList posts={posts} />
}
// ✅ 直行:Server Component → Database
async function Page() {
const posts = await db.post.findMany() // 直接取得
return <PostList posts={posts} />
}
後者の方がレイヤーが1つ少なく、応答が高速です。「微々たる差では?」と思うかもしれませんが、ネットワークのオーバーヘッドやシリアライズ/デシリアライズのコストがなくなるため、ページロードが 100-200ms 速くなることもあります。
2. 優れた型安全性
TypeScript + Prisma/Drizzle を使用している場合、直接クエリなら完全な型推論が得られます:
// エディタの補完が完璧に効く
const post = await db.post.findFirst({
include: { author: true, comments: true }
})
// post.author.name ← 型定義あり
// post.comments[0].content ← 型定義あり
fetch の場合、戻り値の型を手動で定義したり as アサーションを使う必要があり、ミスの原因になります。
3. コードが簡潔
API ルートのファイルを作る必要がなく、HTTP ステータスコードやエラーレスポンスの処理も不要です。
いつ API/fetch を使うべきか?
もちろん、API ルートが不要になるわけではありません。以下のようなケースでは必須です:
ケース1:Client Component でデータが必要な場合
ブラウザで動くコンポーネントは DB に直接アクセスできないため、API エンドポイントが必要です:
// app/api/posts/route.ts
export async function GET() {
return Response.json(await db.post.findMany())
}
// components/client-posts.tsx
'use client'
export function ClientPosts() {
const [posts, setPosts] = useState([])
useEffect(() => {
fetch('/api/posts').then(res => res.json()).then(setPosts)
}, [])
// ...
}
ケース2:外部に API を公開する場合
モバイルアプリやサードパーティ向けにデータを提供する場合。
ケース3:サードパーティサービスの利用
GitHub API や OpenAI API などは fetch で叩くしかありません。
async function Page() {
const res = await fetch('https://api.github.com/users/vercel')
// ...
}
async/await コンポーネントの書き方とパターン
並列取得 vs 直列取得:パフォーマンスの要
ここにはよくある落とし穴があります。
以下のコードはどちらが速いでしょうか?
// ❌ 直列(Waterfall):遅い
async function Page() {
const user = await db.user.findFirst() // 200ms 待機
const posts = await db.post.findMany() // さらに 150ms 待機
const comments = await db.comment.findMany() // さらに 100ms 待機
// 合計 450ms
return <Dashboard user={user} posts={posts} comments={comments} />
}
// ✅ 並列:速い
async function Page() {
const [user, posts, comments] = await Promise.all([
db.user.findFirst(), // 同時発火
db.post.findMany(), // 同時発火
db.comment.findMany(), // 同時発火
])
// 合計 200ms(最も遅い処理の時間)
return <Dashboard user={user} posts={posts} comments={comments} />
}
データ間に依存関係がないなら、必ず Promise.all で並列化しましょう。2倍以上の差が出ることもあります。
Suspense 境界:ローディング体験の制御
「サーバーでデータを待っていたら、画面が白くならない?」
その通りです。だからこそ Next.js には loading.js と Suspense があります。
方法1:loading.js
ルートディレクトリに置くだけで自動的に適用されます:
// app/posts/loading.tsx
export default function Loading() {
return <div>Loading posts...</div>
}
方法2:手動 Suspense(推奨)
よりきめ細かく制御したい場合、遅いコンポーネントだけをラップします:
import { Suspense } from 'react'
async function SlowComponent() {
const data = await slowQuery() // 3秒かかる
return <div>{data}</div>
}
export default function Page() {
return (
<div>
<h1>My Page</h1>
<FastComponent /> {/* すぐ表示される */}
<Suspense fallback={<div>Loading stats...</div>}>
<SlowComponent /> {/* ここだけローディング表示 */}
</Suspense>
</div>
)
}
よくある間違い:Suspense の配置
// ❌ 間違い:async コンポーネント内で Suspense を使っても無意味
async function Page() {
return (
<Suspense>
{await slowQuery()} {/* ここで既に待機しているため Suspense は効かない */}
</Suspense>
)
}
// ✅ 正解:async コンポーネントを外からラップする
export default function Layout() {
return (
<Suspense>
<SlowPage />
</Suspense>
)
}
リクエストの自動重複排除 (Request Memoization)
React は fetch リクエストを自動的にメモ化(重複排除)します。
async function Header() {
const user = await getUser() // リクエスト1
return <div>{user.name}</div>
}
async function Sidebar() {
const user = await getUser() // リクエスト2(キャッシュから返却され、実際には飛ばない)
return <div>{user.role}</div>
}
同じレンダリングパス内であれば、同じ URL への fetch は一度しか実行されません。これにより、Props のバケツリレーを避けて、必要な場所でデータを取得するパターンが可能になります。
注意:fetch 以外の関数(DBクエリなど)には自動適用されません。React の cache 関数を使って手動でメモ化する必要があります。
import { cache } from 'react'
import { db } from '@/lib/db'
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } })
})
キャッシュ戦略:Next.js 15 の変更点
Next.js 15 でキャッシュのデフォルト挙動が大きく変わりました。
Next.js 14 まで:fetch はデフォルトで force-cache(永久キャッシュ)。
Next.js 15 から:fetch はデフォルトで no-store(キャッシュなし)。
「データが更新されない!」という苦情が多かったため、より直感的な挙動(常に最新)に変更されました。
3つのキャッシュ戦略
1. 完全キャッシュ(静的コンテンツ)
async function BlogPost({ slug }) {
const post = await fetch(`https://api.example.com/posts/${slug}`, {
cache: 'force-cache' // ビルド時に取得され、永続キャッシュ
})
// ...
}
2. キャッシュなし(リアルタイムデータ)
Next.js 15 のデフォルトですが、明示的に書くなら:
async function StockPrice() {
const price = await fetch('...', {
cache: 'no-store'
})
// ...
}
3. ISR(定期的再検証)
async function ProductList() {
const products = await fetch('...', {
next: { revalidate: 60 } // 60秒間はキャッシュ、その後裏で更新
})
// ...
}
データの更新(Revalidation)
キャッシュを手動でパージしたい場合(例:CMS で記事を更新した時など):
-
revalidatePath: 特定のパスのキャッシュをクリア
revalidatePath('/blog/[slug]') -
revalidateTag: 特定のタグが付いたキャッシュを一括クリア
// データ取得時 fetch(url, { next: { tags: ['posts'] } }) // Server Action で revalidateTag('posts')
エラー処理の実践
方法1:try/catch
最も基本的ですが、コンポーネントレベルで制御できます。
async function Page() {
try {
const res = await fetch('...')
if (!res.ok) throw new Error('Fetch failed')
// ...
} catch (error) {
return <div>データの取得に失敗しました</div>
}
}
方法2:error.js
ルート単位でエラーを捕捉するバリアです。
// app/dashboard/error.tsx
'use client' // エラー境界は Client Component である必要がある
export default function Error({ error, reset }: { error: Error, reset: () => void }) {
return (
<div>
<h2>エラーが発生しました</h2>
<button onClick={() => reset()}>再試行</button>
</div>
)
}
重要テクニック:redirect の罠
redirect 関数は内部的に特別なエラーを投げることで動作します。try/catch ブロック内で使うと、そのエラーがキャッチされてしまい、リダイレクトが失敗します。
// ❌ 失敗:redirect がキャッチされる
try {
const user = await getUser()
if (!user) redirect('/login')
} catch (e) {
// redirect のエラーがここに来てしまう
}
// ✅ 成功:redirect はブロック外で
let user
try {
user = await getUser()
} catch (e) {
// ログ出力など
}
if (!user) redirect('/login')
まとめ:シンプルに考えよう
Next.js App Router のデータ取得は、一見複雑に見えるかもしれませんが、以下の原則に従えばシンプルです:
- Server Component なら DB を直接叩く。API ルートを作らない。
- Client Component で必要なら API ルートを作る。
- 独立したデータ取得は
Promise.allで並列化する。 - Next.js 15 では
fetchはデフォルトでキャッシュされない。必要なら設定する。 - エラー処理とサスペンスを活用してユーザー体験を守る。
クライアントサイドでの複雑な状態管理から解放され、サーバーでシンプルにデータを取得する快感を、ぜひ体験してください。
Next.js Server Component データ取得フロー
コンポーネント内での非同期データ取得からエラー処理までの実装手順
⏱️ Estimated time: 15 min
- 1
Step1: コンポーネントの定義
コンポーネントを async function として定義します。
async function Page({ params }:Props) {
// ...
} - 2
Step2: データの取得
DB クライアントを使って直接データを取得します。依存関係のない複数のデータは Promise.all で並列化します。
const [user, posts] = await Promise.all([
db.user.findFirst(),
db.post.findMany()
]) - 3
Step3: ローディング処理
遅い処理がある場合は、Suspense コンポーネントでラップし、fallback UI を提供します。
<Suspense fallback={<LoadingSkeleton />}>
<SlowComponent />
</Suspense> - 4
Step4: エラー処理
try/catch ブロックを使用するか、error.tsx ファイルを作成してエラー時の UI を定義します。
FAQ
Server Component で API Route を呼ぶのは悪いことですか?
fetch を使わない場合、キャッシュはどうすればいいですか?
Server Actions はデータ取得に使えますか?
クライアントコンポーネントで async/await は使えますか?
4 min read · 公開日: 2025年12月19日 · 更新日: 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アカウントでログインしてコメントできます