Next.js 16マイグレーション完全ガイド:Turbopack、proxy.ts、Cache Components、全Breaking Change解説
CIパイプラインがnextを^16.0.0に上げた途端に壊れました。エラーログは400行、ミドルウェアの半分が動かない、見たこともないproxy.tsというファイルコンベンションが登場している。Next.js 16アップグレードの世界へようこそ。
Next.js 16は、App Router登場以降最大のアーキテクチャ変更。Webpackが消えてTurbopack一本、middleware.tsはproxy.tsに変わり、キャッシュはuse cacheに統一された。コンテナ環境で動かしているなら、メモリ周りの挙動変更も把握しておかないと本番で痛い目を見る。
この記事では全breaking changeを一つずつ潰し、変更理由、codemodコマンド、手動マイグレーションパスまでカバーする。小規模サイトでも200ルートのエンタープライズアプリでも、この記事一本で足りる。
何が変わって、なぜ変わったのか
マイグレーション手順に入る前に、Next.js 16が何をどう変えたかの全体像を把握しましょう:
┌─────────────────────────────────────────────────────────────┐
│ Next.js 16 アーキテクチャ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ Turbopack │ │ proxy.ts │ │ Cache │ │
│ │ (唯一の │ │ (middleware │ │ Components │ │
│ │ バンドラー) │ │ を置換) │ │ ('use cache') │ │
│ └──────┬───────┘ └──────┬───────┘ └────────┬────────┘ │
│ │ │ │ │
│ ┌──────┴───────┐ ┌──────┴───────┐ ┌────────┴────────┐ │
│ │ 10倍速い │ │ 明確な │ │ 宣言的 │ │
│ │ HMR & ビルド│ │ ネットワーク│ │ キャッシュ + │ │
│ │ │ │ 境界 │ │ 細かいTTL制御 │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 非同期 │ │ PPR │ │
│ │ Request API │ │ (デフォルト)│ │
│ │ (params, │ │ │ │
│ │ cookies, │ │ │ │
│ │ headers) │ │ │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
| 変更 | 理由 |
|---|---|
| TurbopackがWebpackを置換 | Webpackのアーキテクチャではモジュール500個を超えるとサブセカンドHMRがスケールしなかった。Turbopackのインクリメンタル計算エンジンは10K+モジュールのグラフでも一貫して200ms以内の更新を維持する。 |
proxy.tsがmiddleware.tsを置換 | middlewareがリクエストレベルのロジック(認証、リダイレクト)とネットワークプロキシ動作(リライト、ヘッダーインジェクション)を混在させていた。proxy.tsでEdge実行コードとサーバー実行コードの境界が明確になった。 |
| 非同期Request API | params、searchParams、cookies()、headers()がすべてasync化。ストリーミングが改善され、RSCレンダリングでの暗黙的ブロッキングがなくなった。 |
Cache Components (use cache) | 従来のrevalidate、unstable_cache、ネストしたcache()パターンが予測不能なキャッシュ階層を作っていた。use cacheで単一の宣言的かつ合成可能なプリミティブに統一。 |
| PPRがデフォルト | Partial Prerenderingがデフォルトレンダリング戦略に。静的シェルにストリーミングで動的コンテンツを組み合わせ、SSRかSSGかで悩む必要がなくなった。 |
ステップ0:始める前に
1. 現在のメトリクスを記録する
コードをいじる前に、今のパフォーマンスを記録しておきましょう:
# パフォーマンスベースライン npx next build 2>&1 | tee build-before.log npx @next/bundle-analyzer # ランタイムメトリクス lighthouse https://your-app.com --output json --output-path baseline.json
2. Node.js互換性の確認
Next.js 16はNode.js 20.x以上が必要です。Node 18はサポート外。
node -v # v20.0.0以上であること
3. アップグレードコマンドの実行
npx @next/codemod@latest upgrade
自動マイグレーションのかなりの部分を処理してくれます。ただし全部はカバーしきれない。残りはこのガイドで解説します。
ステップ1:Turbopack — Webpackはもうデフォルトじゃない
一番目立つ変更。devもbuildもTurbopackがデフォルトになった。next.config.tsのwebpackキーはdeprecatedだが、どうしても互換性のないローダーがあるならnext dev --webpackやnext build --webpackで一時的に通る。あくまで繋ぎなので、最終的にはこれを外すのがゴール。
何が壊れるか
next.config.tsにこういうのがあると全部失敗します:
// ❌ Next.js 16では動作しない module.exports = { webpack: (config) => { config.resolve.alias['@'] = path.resolve(__dirname, 'src'); config.module.rules.push({ test: /\.svg$/, use: ['@svgr/webpack'], }); return config; }, };
マイグレーション方法
エイリアス: tsconfig.jsonのビルトインpathsを使いましょう。Turbopackがネイティブで認識します:
// tsconfig.json { "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }
SVGインポート: @svgr/webpackの代わりに@svgr/turbopackを使用:
npm install @svgr/turbopack
// next.config.ts import type { NextConfig } from 'next'; const nextConfig: NextConfig = { turbopack: { rules: { '*.svg': { loaders: ['@svgr/turbopack'], as: '*.js', }, }, }, }; export default nextConfig;
その他のカスタムローダー: Turbopack互換バージョンがあるか確認しましょう。人気のローダー(CSSモジュール、SASS、画像最適化)はTurbopackにビルトインです。それ以外はturbopack.rules設定がエスケープハッチになります。
検証
# 開発 npx next dev # エラーなく起動すればTurbopack正常動作 # プロダクションビルド npx next build
ビルド中に以前なかったModule not foundエラーが出たら、ほぼ確実にローダー互換性の問題です。使用中のローダーについてTurbopack互換性テーブルを確認してください。
ステップ2:middleware.ts → proxy.ts
最も混乱を招く変更がこれ。従来のmiddleware.tsが2つの概念レイヤーに分離されました:
proxy.ts— ネットワークレベルの操作(URLリライト、ヘッダーインジェクション、ジオロケーションルーティング)。デフォルトでNode.jsランタイムで実行(Edgeがデフォルトだった旧middlewareとは異なる)。- ルートハンドラー内のサーバーサイドロジック — 認証チェック、セッション検証、ビジネスロジックは実際のルートハンドラー、レイアウト、Server Actionsに置くべき。
典型的なmiddleware.tsはこうだった
// ❌ 旧:middleware.ts(Next.js 15) import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export function middleware(request: NextRequest) { // 認証チェック const token = request.cookies.get('session-token'); if (!token && request.nextUrl.pathname.startsWith('/dashboard')) { return NextResponse.redirect(new URL('/login', request.url)); } // ロケール検出 const locale = request.headers.get('Accept-Language')?.split(',')[0] || 'en'; const response = NextResponse.next(); response.headers.set('x-locale', locale); // A/Bテストルーティング const bucket = Math.random() > 0.5 ? 'a' : 'b'; response.headers.set('x-ab-bucket', bucket); return response; } export const config = { matcher: ['/((?!api|_next/static|favicon.ico).*)'], };
新しいproxy.ts
// ✅ 新:proxy.ts(Next.js 16) import type { NextRequest } from 'next/server'; export function proxy(request: NextRequest) { const url = request.nextUrl.clone(); // ロケール検出(ネットワークレベルの関心事) const locale = request.headers.get('Accept-Language')?.split(',')[0] || 'en'; // A/Bテストルーティング(ネットワークレベルの関心事) const bucket = Math.random() > 0.5 ? 'a' : 'b'; return { headers: { 'x-locale': locale, 'x-ab-bucket': bucket, }, }; } export const config = { matcher: ['/((?!api|_next/static|favicon.ico).*)'], };
認証ロジックはどこへ行く?
認証チェックは、保護するルートのレイアウトやルートハンドラーに移動します:
// app/dashboard/layout.tsx import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth'; export default async function DashboardLayout({ children, }: { children: React.ReactNode; }) { const session = await getSession(); if (!session) { redirect('/login'); } return <>{children}</>; }
実際、この方が設計として正しい。認証ロジックが守るルートのすぐ横にあるので、「どこで弾かれてるの?」と迷うことがなくなる。グローバルなmiddlewareに隠していた頃とは比較にならない。
Codemod
npx @next/codemod@latest middleware-to-proxy
シンプルなヘッダー/リライトパターンは処理してくれますが、認証ロジックの自動移動はしません。出力を必ず確認しましょう。
ステップ3:非同期Request API
すべてのダイナミックリクエストAPIが厳密にasyncになりました。ファイル数ベースで最もインパクトの大きい変更です。params、search params、cookies、headersにアクセスするすべてのページ、レイアウト、ルートハンドラーを修正する必要があります。
Before(Next.js 15)
// ❌ 同期アクセスはもう動かない export default function Page({ params, searchParams, }: { params: { slug: string }; searchParams: { q?: string }; }) { const title = params.slug; const query = searchParams.q; return <div>{title} - {query}</div>; }
After(Next.js 16)
// ✅ すべてのRequest APIがasync export default async function Page({ params, searchParams, }: { params: Promise<{ slug: string }>; searchParams: Promise<{ q?: string }>; }) { const { slug } = await params; const { q } = await searchParams; return <div>{slug} - {q}</div>; }
cookies()とheaders()
// ❌ Before import { cookies, headers } from 'next/headers'; export default function Page() { const cookieStore = cookies(); const headerList = headers(); // ... } // ✅ After import { cookies, headers } from 'next/headers'; export default async function Page() { const cookieStore = await cookies(); const headerList = await headers(); // ... }
Codemod
npx @next/codemod@latest async-request-apis
このcodemodは成功率が高い(~90%)。関数にasyncを追加し、awaitでラップし、型シグネチャも更新してくれます。結果は必ずチェックしましょう。paramsを別のユーティリティ関数にパススルーしているケースで漏れることがあります。
ステップ4:Cache Componentsとuse cacheディレクティブ
一番大きな概念の変化。キャッシュ設定するとき、revalidate使うか、unstable_cache使うか、fetchのオプション使うか、毎回迷ってたでしょ。あのカオスがuse cache一本に整理された。
旧世界(混乱)
// ❌ Next.js 15:複数のキャッシュメカニズムが重複 export const revalidate = 3600; // ページレベルの再検証 async function getData() { const res = await fetch('https://api.example.com/data', { next: { revalidate: 60, tags: ['data'] }, }); return res.json(); } // さらに:unstable_cache、cache()、generateStaticParams...
新世界(宣言的)
まず、設定でCache Componentsを有効化します:
// next.config.ts const nextConfig: NextConfig = { cacheComponents: true, // 'use cache' の使用に必須 };
それからディレクティブを使います:
// ✅ Next.js 16:単一の'use cache'ディレクティブ import { cacheLife, cacheTag } from 'next/cache'; // 関数レベルキャッシュ async function getData() { 'use cache'; cacheLife('hours'); cacheTag('data'); const res = await fetch('https://api.example.com/data'); return res.json(); } // コンポーネントレベルキャッシュ async function ExpensiveWidget() { 'use cache'; cacheLife('days'); cacheTag('widget'); const data = await getData(); return <div>{data.title}</div>; } // ページレベルキャッシュ export default async function Page() { 'use cache'; cacheLife('minutes'); return ( <main> <ExpensiveWidget /> <DynamicContent /> </main> ); }
Cache Lifeプリセット
Next.js 16にはビルトインのキャッシュ寿命プリセットが含まれています:
| プリセット | Stale | Revalidate | Expire |
|---|---|---|---|
'seconds' | 0秒 | 1秒 | 60秒 |
'minutes' | 5分 | 1分 | 1時間 |
'hours' | 5分 | 1時間 | 24時間 |
'days' | 5分 | 1日 | 14日 |
'weeks' | 5分 | 1週間 | 30日 |
'max' | 5分 | 30日 | 365日 |
カスタムプロファイルも定義可能:
// next.config.ts const nextConfig: NextConfig = { cacheLife: { product: { stale: 300, // 5分 revalidate: 3600, // 1時間 expire: 86400, // 1日 }, }, };
// こう使う: async function getProduct(id: string) { 'use cache'; cacheLife('product'); cacheTag(`product-${id}`); return db.products.findById(id); }
再検証(Revalidation)
タグベースの再検証は同じ仕組みですが、どの粒度でもタグ付けできるようになったので、より強力になりました:
// app/api/revalidate/route.ts import { revalidateTag } from 'next/cache'; export async function POST(request: Request) { const { tag } = await request.json(); revalidateTag(tag); return Response.json({ revalidated: true }); }
マイグレーション戦略
- ページから
export const revalidate = ...を全部削除 fetch(..., { next: { revalidate } })をuse cache+cacheLifeに置換unstable_cache()呼び出しをuse cache関数に置換- ターゲット再検証が必要な箇所に
cacheTag()を追加
ステップ5:PPR(Partial Prerendering)がデフォルトに
PPRがデフォルトのレンダリング戦略になりました。すべてのページが自動的に即座に配信される静的シェルを持ち、動的コンテンツはSuspenseバウンダリ経由でストリーミングされます。
実際にどう動くか
// Next.js 16でこのページは自動的にPPRを使用 export default async function ProductPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; return ( <main> {/* 静的シェル — CDNから配信 */} <Header /> <ProductInfo id={id} /> {/* 動的コンテンツ — ストリーミング */} <Suspense fallback={<PriceSkeleton />}> <DynamicPrice id={id} /> </Suspense> <Suspense fallback={<ReviewsSkeleton />}> <UserReviews id={id} /> </Suspense> </main> ); }
ポイントはシンプル。use cacheが付いたコンポーネント = 静的シェル。 cookiesやheadersなど動的データを使うコンポーネント = ストリーミングで後から埋まる穴。これだけ覚えればPPRは理解できた。
PPRを無効にしたい場合
// next.config.ts const nextConfig: NextConfig = { experimental: { ppr: false, // PPRをグローバル無効化 }, };
ルートごとの無効化も可能:
// app/legacy-page/page.tsx export const dynamic = 'force-dynamic'; // このページはPPRを使わない
ステップ6:コンテナのメモリ最適化
地味にハマるやつ。Next.js 16のRSCレンダリングはv15より明らかにメモリを食う。コンテナのメモリ制限がタイトだとOOMでPodが落ちる。
問題
KubernetesやDockerデプロイでメモリ制限(例:Pod当たり512MB)を設定している場合、Next.js 15では起きなかったOOM killが発生する可能性があります。原因はTurbopackのインメモリモジュールグラフとRSCレンダリングエンジンがより多くの中間状態を保持すること。
解決策
// next.config.ts const nextConfig: NextConfig = { turbopack: { memoryLimit: 256 * 1024 * 1024, // 256MB }, experimental: { incrementalCacheHandlerPath: './cache-handler.mjs', }, };
// cache-handler.mjs import { CacheHandler } from '@next/cache-handler-redis'; export default class CustomCacheHandler extends CacheHandler { constructor(options) { super({ ...options, redis: { url: process.env.REDIS_URL, }, inMemoryCacheEnabled: false, }); } }
コンテナリソース推奨値
| アプリ規模 | 推奨メモリ | 推奨CPU |
|---|---|---|
| 小規模(<50ルート) | 512MB | 0.5 vCPU |
| 中規模(50-200ルート) | 1GB | 1 vCPU |
| 大規模(200+ルート) | 2GB | 2 vCPU |
モニタリング:
# ビルド中のメモリ使用量を監視 docker stats --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}" # ランタイムNode.jsメモリプロファイリング NODE_OPTIONS="--max-old-space-size=1024 --heapsnapshot-near-heap-limit=3" npm start
ステップ7:next.config.tsの変更
いくつかの設定オプションがリネームまたは構造変更されました:
// next.config.ts — Next.js 16完全設定 import type { NextConfig } from 'next'; const nextConfig: NextConfig = { turbopack: { rules: { '*.svg': { loaders: ['@svgr/turbopack'], as: '*.js', }, }, resolveAlias: { 'legacy-lib': './src/lib/legacy-adapter', }, }, cacheLife: { product: { stale: 300, revalidate: 3600, expire: 86400 }, blog: { stale: 60, revalidate: 900, expire: 86400 }, }, images: { remotePatterns: [ { protocol: 'https', hostname: '**.example.com' }, ], }, async redirects() { return [ { source: '/old-path', destination: '/new-path', permanent: true }, ]; }, }; export default nextConfig;
削除されたオプション
これらのnext.config.tsオプションはもう存在しません:
// ❌ Next.js 16ですべて削除 { webpack: () => {}, // turbopack.rules を使用 swcMinify: true, // 常にON(Turbopack) experimental: { appDir: true, // v14から常にON serverActions: true, // v15から常にON typedRoutes: true, // 常にON }, }
マイグレーションチェックリスト完全版
codemod実行後、このチェックリストを確認していきましょう:
インフラ
- Node.js >= 20.x インストール済み
-
nextを^16.0.0にアップグレード -
reactとreact-domを^19.2.0にアップグレード - すべての
@next/*パッケージのバージョンを揃える - コンテナメモリ制限を確認(512MB未満なら増加)
バンドラー
-
next.config.tsからすべてのwebpack設定を削除 - カスタムローダーを
turbopack.rulesに移行 -
tsconfig.jsonのpathsがTurbopackで動作確認 -
npx next buildがエラーなく完了
ルーティング
-
middleware.ts→proxy.ts移行(ネットワーク関心事のみ) - 認証ロジックをmiddlewareからlayout/route handlerに移動
-
proxy.tsのmatcherパターンを確認
データフェッチ
- すべての
paramsとsearchParamsがPromise<T>+awaitに - すべての
cookies()とheaders()呼び出しにawait追加 -
export const revalidate→use cache+cacheLifeに変換 -
next.config.tsでcacheComponents: trueを有効化 -
unstable_cache→use cache関数に置換 - ターゲット再検証のため
cacheTag()追加
レンダリング
- SuspenseバウンダリでPPR動作を確認
- 動的コンテンツ用スケルトンコンポーネント追加
- PPR不要な箇所で
dynamic = 'force-dynamic'テスト
プロダクション
- ビルド時間ベンチマーク(Turbopackで改善を確認)
- 本番トラフィックパターンでロードテスト
- コンテナメモリ使用量を24時間モニタリング
- プロダクションキャッシュヒット率を確認
実際のマイグレーションタイムライン
さまざまな規模のチームでの本番マイグレーション実績に基づく:
| アプリ規模 | Codemodカバレッジ | 手動作業 | 合計期間 |
|---|---|---|---|
| 小規模(<50ルート) | ~85% | 1-2日 | 3-4日 |
| 中規模(50-200ルート) | ~75% | 3-5日 | 1-2週間 |
| 大規模(200+ルート) | ~60% | 1-2週間 | 3-4週間 |
最も時間を食うのは:
- カスタムWebpackローダー → Turbopack互換品探し
- 複雑なmiddlewareロジック → proxy + layout認証への分解
- キャッシュ戦略の再設計 → 旧
revalidateパターンからuse cacheへのマッピング
マイグレーション後に期待できること
クリーンなマイグレーション後、チームからよく報告される結果:
- 開発サーバー起動:60-80%高速化(Turbopack vs Webpack)
- HMR更新:5-10倍高速化(一貫して200ms以内)
- プロダクションビルド:20-40%高速化
- TTFB:PPRで30-50%改善(静的シェル即時配信)
- メモリ使用量:同等かやや増加(チューニング必要)
TurbopackとPPRのパフォーマンス向上だけで、マイグレーションの苦労は十分元が取れる。proxy.tsとuse cacheでコードが綺麗になるのは、アプリが大きくなるほど効いてくるボーナス。
Next.js 16はオピニオンが強い。良いパターンを強制するが、先行投資としてのマイグレーション作業が必要。codemodで機械的な部分は処理できる。アーキテクチャの理解 — proxy分離、宣言的キャッシュ、PPR — は自分で消化するしかない。この記事で両方揃えた。アップグレードしよう。
関連ツールを見る
Pockitの無料開発者ツールを試してみましょう