個人開発でチャートリプレイ型のトレード練習サイト(kabu-renshu.com)をNext.js 14で作っていましたが、npm audit に残っていたNext.js 14系のhigh脆弱性(修正版が16系のみでバックポートなし)を根治するため、2026年9月にNext.js 16へ移行しました。公式の upgrade codemodを使って14→16へ一気に上げる方針を取りましたが、codemod任せでは終わらない箇所がいくつかあったので、実際に何を直したかを記録します。
環境
| 項目 | 移行前 | 移行後 |
|---|---|---|
| next | 14.2.35 | 16.3.4 |
| react / react-dom | 18 | 19.2.8 |
| eslint / eslint-config-next | 8 / 14.2.35 | 9.39.5 / 16.3.4 |
| typescript | 5系 | 変更なし |
やったこと一覧
| # | 症状 | 対処 |
|---|---|---|
| 1 | codemodがページに挿入したexport const instant = falseでビルドエラー |
cacheComponents未使用のため除去 |
| 2 |
experimental.outputFileTracingIncludesにビルド警告 |
トップレベルのoutputFileTracingIncludesへ移動 |
| 3 |
middleware.tsが非推奨に |
src/proxy.tsへリネームしexport function proxyに |
| 4 | 動的ルートのparamsが同期参照のまま |
next-async-request-api codemod+手動でawait params化 |
| 5 | OGP画像ルートのparams未対応・runtime='edge'が不要に |
opengraph-image.tsxをawait params化、export const runtime = 'edge'を削除 |
| 6 |
next lint廃止でlintコマンドが動かない |
.eslintrc.jsonをeslint.config.mjs(flat config)へ移行 |
以下、それぞれの中身です。
1. codemodがページに入れたinstant = falseでビルドが落ちた
npx @next/codemod@canary upgrade latest を対話的に進めると、途中でcache-components-instant-falseという別のcodemodの実行を勧められます。これはcacheComponentsを有効化する準備として、app配下のpage/layoutファイルにexport const instant = falseを挿入するものです。
このアプリはcacheComponentsを使っていません。その状態でinstantが付いたページがあるとnext buildが「instantはcacheComponents有効時のみ使える」趣旨のエラーで止まりました。対処は挿入された行を全て取り除くだけです。コミットにはその旨だけ残しています。
chore(next16): 依存更新とcodemod適用(next 16.3.4 / react 19.2 / proxy化 / async params)
- npx @next/codemod@canary upgrade latest の成果物。codemodが付与した instant=false は
cacheComponents無効のためビルドエラーになるので除去
除去してからコミットしたので、リポジトリの差分にはinstantの痕跡が残っていません。教訓は「upgrade codemodが勧めてくる追加codemodは、自分のプロジェクトの前提(ここではcacheComponentsを使うかどうか)を確認してから受けるかどうか決める」で、codemodの出力をgit diff --statで確認してから1コミットにまとめる運用にしていたおかげで検出できました。
2. outputFileTracingIncludesの配置
/practice/[id]は動的ルートですが、記事タイトルの読み取りにcontent/blog配下のファイルをサーバーレス関数へ同梱する必要があり、これをexperimental.outputFileTracingIncludesで指定していました。16でビルドすると「トップレベルへ移すように」という警告が出たので、それに従って移動しました(どのバージョンで昇格したかは公式ガイドで確認できていません)。
const nextConfig = {
- experimental: {
- outputFileTracingIncludes: {
- '/practice/[id]': ['./content/blog/**'],
- },
+ outputFileTracingIncludes: {
+ '/practice/[id]': ['./content/blog/**'],
},
ビルド警告に従って移すだけですが、/practice/[id]で関連記事タイトルが空になっていないかをPreview環境で目視確認しています。
3. middleware.ts→proxy.ts
Next.js 16でmiddlewareは非推奨になり、ファイル名proxy.ts・export名proxyが新しい形になりました。このアプリのmiddlewareはUser-Agent判定によるボットブロックのみで、edge固有のAPIは使っていなかったため、単純なリネームで済みました。
-import { type NextRequest, NextResponse } from 'next/server';
-import { isBlockedUserAgent } from '@/lib/bot-block';
-
-export function middleware(request: NextRequest): NextResponse {
+export function proxy(request: NextRequest): NextResponse {
if (isBlockedUserAgent(request.headers.get('user-agent'))) {
return new NextResponse('Forbidden', { status: 403 });
}
proxyはNode.jsランタイム固定になる仕様変更も伴いますが、もともとUser-Agent判定だけの軽量な処理だったため、挙動面での影響は確認していません。
4. 動的ルートのparamsが非同期化
Next.js 15〜16でapp routerのparams/searchParamsはPromiseになりました。npx @next/codemod@canary next-async-request-api . で機械変換したうえで、/api/scenarios/[id]/route.tsやblog/[slug]/page.tsxなど6ファイル前後を手直ししています。
interface Props {
- params: { slug: string };
+ params: Promise<{ slug: string }>;
}
-export async function generateMetadata({ params }: Props): Promise<Metadata> {
+export async function generateMetadata(props: Props): Promise<Metadata> {
+ const params = await props.params;
const post = getPostBySlug(params.slug);
Route Handlerも同様です。
-export async function GET(
- _request: NextRequest,
- { params }: { params: { id: string } }
-) {
+export async function GET(_request: NextRequest, props: { params: Promise<{ id: string }> }) {
+ const params = await props.params;
const { id } = params;
cookies()/headers()の直接呼び出しはこのアプリには無かったため、対応箇所はparamsのPromise化のみでした。
5. OGP画像ルートのparamsとedge runtime
opengraph-image.tsxもPageコンポーネントと同様にparamsがPromise化されており、next-async-request-apiのcodemod対象からは漏れていたため手動で修正しました。
interface Props {
- params: { slug: string };
+ params: Promise<{ slug: string }>;
}
-export default async function Image({ params }: Props) {
+export default async function Image(props: Props) {
+ const params = await props.params;
const post = BLOG_META[params.slug];
あわせて、トップページのOGP画像ルート(paramsなし)に付けていたexport const runtime = 'edge'は不要になったため削除しています。
import { ImageResponse } from 'next/og';
-export const runtime = 'edge';
export const alt = '株の練習シミュレーター|無料チャートリプレイで株トレード練習';
このアプリのOGP画像ルートはgenerateStaticParamsを付けるとビルド時プリレンダでnext/ogがInvalid URLエラーを起こすため、あえてリクエスト時生成にしている経緯があり、ローカル(Windows)環境では検証できず、Vercel Previewで初めて実機確認できる箇所でした。
6. next lint廃止とESLint flat configへの移行
Next.js 16でnext lintコマンドが廃止され、ESLintの実行はプロジェクト側の責任になりました。.eslintrc.jsonをeslint.config.mjsのflat configへ移行し、lintスクリプトをeslint .に変更しています。
import { defineConfig } from "eslint/config";
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
export default defineConfig([
{
ignores: [".next/**", "node_modules/**", "seed/**", "_old/**", "docs/**", "public/**"],
},
{
extends: [...nextCoreWebVitals, ...nextTypescript],
rules: {
"react-hooks/refs": "off",
"react-hooks/set-state-in-effect": "off",
"react-hooks/purity": "off",
"react-hooks/immutability": "off",
"react-hooks/preserve-manual-memoization": "off",
},
},
]);
next buildはもうlintを実行しないため、作業完了時の手順としてnpm run lintを別途手で回す運用に切り替えています。
React Compiler系の新設ルール30箇所は「直さない」と判断した
eslint-config-next 16に同梱されるeslint-plugin-react-hooks v7で、React Compilerを前提にした新しいルール(react-hooks/refs、set-state-in-effectなど)が有効になり、既存コードで30箇所が該当しました。この5ルールは上記の通りoffにしていますが、機械的に無効化しただけでなく、1件ずつ内容を精査したうえでの判断です。
主な内訳は次の通りです(残りはpurity・immutability・preserve-manual-memoizationが各1〜2件)。
-
effect内でのsetState(10箇所):
useSearchParamsを使うとページ全体が動的化してSSGが崩れるため、あえてuseEffectでURLパラメータやlocalStorageの値を読んでsetStateしている箇所。遅延初期化に書き換えるとSSR時にwindowが無くhydration mismatchを起こすため、意図的な実装として現状維持 -
render中のref同期(12箇所): 主に再生タイマーやチャート描画コールバックが「render後のeffectを待たずに最新値を要求する」ため、render本体で
xxxRef.current = xxxを同期している箇所。useEffect化すると1フレームのタイミングずれが生まれ、約定価格やタイマー処理に影響しうるためリスクが高いと判断 -
安全に直せる3箇所: モーダルのEscapeキー処理用ref同期(
OrderDialog.tsx、ConfirmModal.tsx、FeedbackModal.tsx)はuseEffect化しても影響が限定的だが、React Compiler自体を有効化していないため直しても実行時の恩恵はなく、優先度は低いと結論
React Compilerの本体(reactCompiler: true)についても、PracticeClient.tsxが既にuseCallback/useMemo/refによる手動最適化を広範に施した設計であるため、有効化のメリットは薄く、上記のref依存設計を見直す別タスクとして切り出さない限り着手しない、という判断にしています。
まとめ
Next.js 14→16の移行は、公式のupgrade codemodとnext-async-request-api codemodでほとんどの機械的な変換はカバーされましたが、そのまま鵜呑みにできたわけではありませんでした。
- upgrade codemodが勧める追加codemod(
cache-components-instant-false)は、プロジェクトの前提(cacheComponents未使用)と噛み合わないとビルドを壊す -
experimentalから昇格した設定はビルド警告を読んで手で移す必要がある - OGP画像ルートのようにcodemodの対象から漏れる箇所がある
-
next typegen(next buildでも走る)がtsconfig.jsonを書き換える(targetがES2017になり、.next/typesのincludeが足される)。差分に驚かないよう、これは意図した変更としてそのままコミットした -
next lint廃止に伴うESLint flat config移行では、新設ルールをただoffにするのではなく、なぜそのコードがそう書かれているか(hydration mismatch回避、タイマー処理のタイミング要件など)を1件ずつ確認してから判断した
「codemodを流して終わり」ではなく、差分を1つずつレビューし、プロジェクト固有の設計判断(SSG維持のためのeffect初期化、タイマー精度のためのref同期)と新しいルールがぶつかる箇所を見極める作業が、実質的な移行コストの大半でした。