Next.js API パフォーマンス最適化完全ガイド:キャッシュ戦略、ストリーミング、エッジコンピューティング

金曜日の夜9時、プロダクトマネージャーからチャットでスクリーンショットが送られてきました。ユーザビリティテストの動画で、ブログの一覧ページを開いたとき、ローディングアイコンがなんと5秒間も回り続け、画面は真っ白なまま。右下にはテスターの辛辣なコメントが。「いつの時代のサイトですか?」
Chrome DevTools を開いて確認すると、API リクエストだけで 3200ms もかかっていました。正直焦りました。遅いのは知っていましたが、ここまで酷いとは。最適化を後回しにしていたツケが回ってきたのです。
その後2日間かけて Next.js のパフォーマンス最適化に取り組み、結果としてレスポンスタイムを 3秒から 500ms 以内に短縮することに成功しました。重要なのは技術そのものよりも、いつどの技術を使うべきかを理解することでした。
今日は、私が実践した3つの秘策——キャッシュ戦略の選び方、ストリーミングレスポンスの実装、Edge Functions の活用シーン——についてお話しします。すべてのコードは実戦で検証済みです。
なぜあなたの Next.js API はこんなに遅いのか?
まずは一般的なボトルのネックから。私が排查した3秒の遅延の原因は、いくつかの典型的な問題の積み重ねでした。
最適化されていないデータベースクエリ。コードを見ると、記事ごとに著者情報を個別にクエリするループがありました。いわゆる N+1 問題です。100記事あれば100回のDBリクエスト。遅くて当たり前です。さらにインデックスすら貼られていないテーブルもありました。
キャッシュの欠如。ユーザーがリロードするたびに、サーバーはデータベースを検索し、計算し、フォーマットし直していました。設定情報など月に1回しか変わらないデータさえ、毎秒再計算されていたのです。
全データの一括返送。API は100記事分の完全なコンテンツ(本文含む)を一度に返していました。JSON サイズは 2MB 超。ネットワーク転送だけで1秒かかります。一覧ページに必要なのはタイトルと要約だけなのに。
サーバーの物理的位置。サーバーは米国西海岸にあり、日本からのアクセスは往復だけで 200ms 以上かかります。
Next.js 16 がもたらしたキャッシュの変化
2025年10月、Next.js 16 で重要な変更がありました。暗黙的キャッシュから明示的キャッシュへの移行です。
以前の Next.js は自動的に多くのものをキャッシュしてくれましたが、「これキャッシュされてるの?いつまで?どうやって消すの?」と混乱を招くことが多く、デバッグが困難でした。
現在は、何をいつまでキャッシュするかを明示的に指定する必要があります。手間は増えましたが、制御性は格段に向上しています。
パフォーマンス改善の3つの方向性
問題が分かれば対策は明確です。
- キャッシュ: 既にやったことを繰り返さない
- ストリーミング: 全部終わるのを待たずに、できたものから送る
- エッジ計算: サーバーをユーザーの近くへ持っていく
これらを順に解説します。
キャッシュ戦略:正しい方法を選べば効果絶大
Next.js のキャッシュ機構は複雑(Request Memoization, Data Cache, Full Route Cache, Router Cache…)に見えますが、API Routes において最も重要なのは Data Cache です。これは DB クエリ結果や外部 API のレスポンスをキャッシュする機能です。
シナリオ1:静的データのキャッシュ
サイト設定やカテゴリ一覧など、滅多に変わらないデータは長時間キャッシュしましょう。
// app/api/categories/route.js
export async function GET() {
const data = await fetch('https://api.example.com/categories', {
next: { revalidate: 3600 } // 1時間キャッシュ
})
return Response.json(await data.json())
}これだけで十分です。revalidate: 3600 は「1時間はキャッシュを使い、それ以降はバックグラウンドで再取得する」という意味です。
シナリオ2:ユーザー関連データのキャッシュ
プロフィール情報などは頻繁には変わりませんが、ずっと古いままでは困ります。ここで stale-while-revalidate 戦略が輝きます。
// app/api/user/profile/route.js
export async function GET(request) {
const user = await getUserFromDB()
return new Response(JSON.stringify(user), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 's-maxage=60, stale-while-revalidate=300'
}
})
}この戦略は非常に賢いです。キャッシュがあれば(多少古くても)即座にそれを返し、裏で非同期に新しいデータを取得してキャッシュを更新します。ユーザーは待ち時間ゼロでデータを見られ、次回アクセス時には最新データになっています。
シナリオ3:リアルタイムデータはキャッシュしない
株価やチャットメッセージなど、リアルタイム性が命のデータはキャッシュしてはいけません。
export async function GET() {
const price = await getStockPrice()
return new Response(JSON.stringify(price), {
headers: {
'Cache-Control': 'no-store' // キャッシュしない
}
})
}キャッシュの無効化:データ更新時の対応
ユーザーがプロフィールを更新したのに、キャッシュが古いままでは困ります。更新時にはキャッシュをパージ(無効化)する必要があります。
Next.js は revalidateTag と revalidatePath を提供しています:
// app/api/user/update/route.js
import { revalidateTag } from 'next/cache'
export async function POST(request) {
const data = await request.json()
await updateUserProfile(data)
// ユーザープロファイルに関連するキャッシュを無効化
revalidateTag('user-profile')
return Response.json({ success: true })
}これに対応して、データ取得時にタグを付けておきます:
export async function GET() {
const data = await fetch('db-api/user', {
next: {
revalidate: 3600,
tags: ['user-profile'] // タグ付け
}
})
return Response.json(await data.json())
}これで、更新APIが叩かれると即座に関連キャッシュが無効化され、次回は最新データが取得されます。
よくある落とし穴
罠1:過度なキャッシュ。注文ステータスを1時間キャッシュしてしまい、決済完了したのに「未払い」のまま…なんてことがないように。キャッシュ時間はデータの性質に合わせて慎重に。
罠2:キャッシュのキー設計ミス。ユーザーAのデータがキャッシュされ、ユーザーBにもそれが表示されてしまう(情報漏洩)。キャッシュキーには必ずユーザーIDなどを含めましょう。
ストリーミングレスポンス:大容量データの転送待ちをなくす
キャッシュは計算済みデータには有効ですが、計算そのものが重い場合やデータ量が莫大な場合はどうすればいいでしょう?ここで ストリーミング の出番です。
ストリーミングとは?
従来の API レスポンスはレストランのコース料理のようなもので、全ての料理が出来上がってから一度にテーブルに運ばれてくるイメージです。遅い料理があれば、客はひたすら待ちます。
ストリーミングは、出来た料理から順次運んでくるスタイルです。客(ユーザー)は最初の料理を食べ始められるので、待ち時間を短く感じます。
どんな時に使う?
- 長いリスト:商品一覧、記事一覧、検索結果
- AI生成:ChatGPT のように文字が徐々に出てくるあの動き
- 大容量ファイル:CSVエクスポートなど
- リアルタイムログ
Next.js での実装
ReadableStream を使用するのが一般的です。
// app/api/posts/stream/route.js
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
// データを分割して取得(バッチ処理)
for (let page = 0; page < 5; page++) {
// 20件ずつ取得
const posts = await fetchPostsFromDB({ page, limit: 20 })
// チャンクとして送信
const chunk = JSON.stringify(posts) + '\n'
controller.enqueue(encoder.encode(chunk))
// 処理時間のシミュレーション
await new Promise(r => setTimeout(r, 100))
}
// 送信完了
controller.close()
}
})
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked'
}
})
}フロントエンドでの受信
バックエンドがストリームで送ってくるなら、フロントエンドもそれに対応して少しずつ読み込む必要があります。
async function fetchStreamData() {
const response = await fetch('/api/posts/stream')
const reader = response.body.getReader()
const decoder = new TextDecoder()
let allPosts = []
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value)
// JSONパースしてリストに追加し、UIを更新
const posts = JSON.parse(chunk)
allPosts = [...allPosts, ...posts]
updatePostList(allPosts)
}
}実際の効果
ブログ一覧にストリーミングを導入した結果:
完了までの総時間は変わらなくても、体感速度は劇的に向上します。
Edge Functions:サーバーをユーザーの目の前へ
物理的な距離による遅延は、どんなにコードを最適化しても消せません。光の速度には限界があるからです。これを解決するのが Edge Functions です。
サーバーを世界中(エッジノード)に配置し、ユーザーに最も近いノードでコードを実行させます。
Edge Runtime とは?
Next.js のデフォルトは Node.js Runtime ですが、Edge Runtime は V8 エンジン(ブラウザと同じ)ベースの軽量環境です。
| 特性 | Node.js Runtime | Edge Runtime |
|---|---|---|
| 起動速度 | 100-500ms | 0-5ms |
| API | フル Node.js API | Web 標準 API のみ |
| 適用 | 複雑なビジネスロジック | 軽量ロジック、認証、プロキシ |
| 遅延 | デプロイ先に依存 | 世界中どこでも <50ms |
どんな時に Edge を使うべきか?
シナリオ1:認証・認可
JWT の検証など、DB アクセスを伴わない軽量なチェックはエッジに最適です。
// app/api/auth/route.js
export const runtime = 'edge' // エッジランタイム指定
export async function GET(request) {
const token = request.headers.get('authorization')
// トークン検証(jose ライブラリなどを使用)
const isValid = await verifyToken(token)
if (!isValid) {
return new Response('Unauthorized', { status: 401 })
}
return Response.json({ user: 'authenticated' })
}シナリオ2:地域判定とコンテンツ出し分け
ユーザーの IP アドレスから国を特定し、適切な言語や通貨のコンテンツを返します。
シナリオ3:API プロキシ
クライアントから複数の外部 API を叩く代わりに、エッジでまとめて叩いて結果をマージして返すBFF(Backend for Frontend)パターン。
Edge の制限
「全部 Edge にすればいいのでは?」と思うかもしれませんが、制限があります。
- Node.js 固有 API が使えない:
fsやchild_processは動きません。 - DB 接続:従来の TCP 接続型の DB ドライバは使えません。HTTP ベースの接続(Prisma Data Proxy, Supabase, PlanetScale など)が必要です。
- リソース制限:メモリや実行時間に厳しい制限があります。
結論:ハイブリッドが最強
エッジ層(認証、ルーティングなど)と、中央サーバー層(複雑な処理、DB操作)を組み合わせるのが賢い設計です。
総合実践:ブログ記事一覧 API の最適化
これまでの技術を組み合わせて、最初の「遅い API」をどう改善したか見てみましょう。
改善前
// app/api/posts/route.js
export async function GET() {
// 問題1: 毎回フルスキャン、キャッシュなし
const posts = await db.post.findMany({
take: 100,
include: {
author: true, // 問題2: N+1 クエリになりがち
tags: true
}
})
// 問題3: 本文含む全データを返送
return Response.json(posts)
}ステップ1:クエリ最適化
必要なフィールドだけを取得します。
const posts = await db.post.findMany({
select: {
id: true,
title: true,
summary: true, // 本文は除外
author: { select: { name: true, avatar: true } }
}
})これだけでデータサイズは 2MB → 180KB に。
ステップ2:キャッシュ導入
const posts = await db.post.findMany({
// ...
}, {
next: {
revalidate: 300, // 5分キャッシュ
tags: ['posts']
}
})ステップ3:ストリーミング化
初回アクセス(キャッシュミス時)の対策として、ストリーミングも実装(前述のコード参照)。
ステップ4:エッジでの事前認証(オプション)
エッジ層でトークンチェックを行い、有効なリクエストだけをこの API に通すように構成。
結果比較
| 指標 | 改善前 | 改善後 | 向上率 |
|---|---|---|---|
| 初回応答時間 | 2800ms | 300ms (最初のバッチ) | 89% |
| キャッシュ時応答 | - | 50ms | 98% |
| JSON サイズ | 2.3MB | 180KB | 92% |
| サーバー負荷 | 100% | 10% | 90% |
ユーザーからのクレームは称賛に変わりました。
まとめ
パフォーマンス最適化は一発勝負ではなく、継続的なプロセスです。
- キャッシュ:静的データは長く、動的データは賢くキャッシュする。
- ストリーミング:ユーザーを待たせないために、準備できたデータから見せる。
- エッジ計算:物理的な距離を縮めるために、ロジックを分散させる。
まずは一番遅い API をひとつ選び、これらの技術を適用してみてください。驚くほど改善するはずです。
FAQ
Next.js API キャッシュはいつ無効になりますか?
ストリーミングレスポンスはすべてのAPIに適していますか?
Edge Functions で普通のDBに繋げないのはなぜ?
効果はどうやって測定すればいいですか?
5 min read · 公開日: 2026年1月5日 · 更新日: 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アカウントでログインしてコメントできます