🔧

6万行規模の生成AI SaaSをNext.js 15にアップデートした話

2024/12/03に公開

はじめに

ストリーツ株式会社の@hanamaです。

弊社では、生成AIを活用したメディア向けコンテンツ制作支援SaaSである「apnea」を開発・運営しています。

今回は、先月に行ったapneaのNext.js 15へのアップデートについてご紹介します。本プロダクトは、Next.jsを用いたフルスタックアプリケーションであり、tstsxファイルのみで6万行(論理LOC)を超えるコードベースを有しています。
執筆時点ではこの規模のNext.jsで構築されたアプリケーションのバージョン15へのアップデートに関する事例がネット上で見つけられなかったため、今回はその手順と課題について詳しくお伝えすることで、同様のアップデートを検討している方々の参考になればと思います。

本記事はNext.js Advent Calendar 2024 の12月3日の記事です。

要点まとめ

本記事はアップデート時に遭遇した問題とその対策を実際の作業の時系列に沿って記載しています。長い記事になってしまったため、以下に要点をまとめました。

  • Next.js 15は、React 19RCに依存しているため、多くのライブラリでpeerDependenciesのバージョン不整合によるエラーが発生します。
    • npmの場合、--legacy-peer-depsオプションを付与することでアップデートできます。
    • pnpmyarnを利用している場合は、warningとして表示され、アップデート自体には成功することが多いです。
    依存確認のスクリプト
    check-react-deps.js
      const { execSync } = require("child_process");
      const fs = require("fs");
    
      const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8"));
      const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies };
    
      Object.entries(dependencies).forEach(([pkg, currentVersion]) => {
      try {
          // 現在のバージョンに関するpeerDependenciesを取得
          const currentPeerDepsOutput = execSync(`npm info ${pkg}@${currentVersion} peerDependencies --json`, { encoding: "utf-8" });
          const currentPeerDeps = JSON.parse(currentPeerDepsOutput || "{}");
          const currentReactSupport = currentPeerDeps.react || "No peerDependencies for React";
    
          // 最新バージョンに関するpeerDependenciesを取得
          const latestPeerDepsOutput = execSync(`npm info ${pkg} peerDependencies --json`, { encoding: "utf-8" });
          const latestPeerDeps = JSON.parse(latestPeerDepsOutput || "{}");
          const latestReactSupport = latestPeerDeps.react || "No peerDependencies for React";
    
          // 最新バージョン情報を取得
          const latestVersionOutput = execSync(`npm info ${pkg} version`, { encoding: "utf-8" });
          const latestVersion = latestVersionOutput.trim();
    
          // Reactへの依存があるもののみについて、結果を出力
          if (currentReactSupport !== "No peerDependencies for React" || latestReactSupport !== "No peerDependencies for React") {
          console.log(`${pkg}@${currentVersion} supports React version: ${currentReactSupport}`);
          console.log(`The latest version ${pkg}@${latestVersion} supports React version: ${latestReactSupport}\n`);
          }
      } catch (err) {
          console.error(`Error checking ${pkg}@${currentVersion}:`, err.message);
      }
      });
    
  • よく使われていそうなライブラリで、React 19に対応していないものについてはこちらのセクションを参照ください。
    調査したライブラリ
    • @stripe/react-stripe-js
    • next-auth
    • shadcn/ui
    • react-day-picker
    • @tiptap
  • codemodnext-async-request-apiのエッジケースについて
    • paramsを引数として取るものの、コンポーネント内で利用していないページコンポーネントについては、自動変換が行われずnext buildに失敗することがあるため手動での修正が必要です。

Next.js 15について

Next.js v15.0.0は、2024年10月にリリースされたNext.jsのメジャーバージョンです。現在の最新バージョンはv15.0.3となっています。
15系へのアップデートでの変更点などは、公式ドキュメント公式ブログをご参照ください。

アップデート前の状況

アップデート前の状態は以下の通りです。

  • Node.js: 20.10.0
  • npm: 10.2.3
  • Next.js: 14.2.15
    • App Routerを採用
  • React: 18.3.1
  • 依存ライブラリ
    • dependencies: 約130個のパッケージ
    • devDependencies: 約40個のパッケージ
    • 最新バージョンでないものも含まれる
  • コードベース
    • tsファイル数: 約250ファイル
    • tsxファイル数: 約500ファイル
    • tstsxファイルのみで6万行(論理LOC)を超える

アップデート作業手順

ここからは実際にどのような手順でNext.js 15へのアップデートを試みたかについて順を追って記載していきます。

1. 公式ドキュメント通りに自動アップデートを試す

とりあえず、公式ドキュメントに記載の以下のコマンドを実行してみました。

npx @next/codemod@canary upgrade latest

対話式にいくつかの質問が表示されます。TurboPackはまだ利用しないため、適用せずcodemodの実行とReact 19へのアップデートを選択しました。

React 18のままでもNext.js 15へのアップデートが可能では?と思った方へ

Next.js 15では、引き続きReact 18の利用も可能ですが、Pages Routerのための後方互換性のためであり、App Routerを利用する場合にはReact 19が必要です。

以下公式ブログからの引用です。

In version 15, the App Router uses React 19 RC, and we've also introduced backwards compatibility for React 18 with the Pages Router based on community feedback. If you're using the Pages Router, this allows you to upgrade to React 19 when ready.

アップデートに失敗し、エラーが表示されました。依存ライブラリのpeerDependenciesがNext 15やReact 19に対応していないものがあるようです。
大量のエラーが表示されたため、このままではアップデートが難しいと判断しました。

今後の方針

  1. React 19に対応していない依存ライブラリを洗い出して対応
  2. React 19へのアップデートを行う
  3. Next.js 15へのアップデートを再度実行

2. React 19へのアップデートの準備

まずは利用している依存ライブラリがReact 19に対応しているかを調査しました。

package.jsonに記載の依存ライブラリに対して、npmリポジトリの情報からReact 19への対応状況を調査すれば良いのですが、手作業で行うのは大変なので簡単なスクリプトを書いて調査しました。ステップ1では、直接dependenciesで依存しているライブラリに関するエラーは表示されなかったため、peerDependenciesに関する情報を取得しました。

スクリプトの内容
check-react-deps.js
const { execSync } = require("child_process");
const fs = require("fs");

const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8"));
const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies };

Object.entries(dependencies).forEach(([pkg, currentVersion]) => {
  try {
    // 現在のバージョンに関するpeerDependenciesを取得
    const currentPeerDepsOutput = execSync(`npm info ${pkg}@${currentVersion} peerDependencies --json`, { encoding: "utf-8" });
    const currentPeerDeps = JSON.parse(currentPeerDepsOutput || "{}");
    const currentReactSupport = currentPeerDeps.react || "No peerDependencies for React";

    // 最新バージョンに関するpeerDependenciesを取得
    const latestPeerDepsOutput = execSync(`npm info ${pkg} peerDependencies --json`, { encoding: "utf-8" });
    const latestPeerDeps = JSON.parse(latestPeerDepsOutput || "{}");
    const latestReactSupport = latestPeerDeps.react || "No peerDependencies for React";

    // 最新バージョン情報を取得
    const latestVersionOutput = execSync(`npm info ${pkg} version`, { encoding: "utf-8" });
    const latestVersion = latestVersionOutput.trim();

    // Reactへの依存があるもののみについて、結果を出力
    if (currentReactSupport !== "No peerDependencies for React" || latestReactSupport !== "No peerDependencies for React") {
      console.log(`${pkg}@${currentVersion} supports React version: ${currentReactSupport}`);
      console.log(`The latest version ${pkg}@${latestVersion} supports React version: ${latestReactSupport}\n`);
    }
  } catch (err) {
    console.error(`Error checking ${pkg}@${currentVersion}:`, err.message);
  }
});

このスクリプトを実行した結果の出力例は以下の通りです。
現在のバージョンで対応しているReactのバージョンと、最新バージョンで対応しているReactのバージョンが表示されます。

@radix-ui/react-icons@1.3.0 supports React version: ^16.x || ^17.x || ^18.x
The latest version @radix-ui/react-icons@1.3.2 supports React version: ^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc

この結果を元に各依存ライブラリを以下のように分類した上で、React 19へのアップデートを進めることにしました。

分類 アプデリスク 対応方針
React 19 RCに対応済み 特に対応しない
React 19に対応済み 特に対応しない
(--legacy-peer-depsオプション付与)
今のバージョンではReact 19に対応していないが、最新版では対応済み 最新版にアップデート
破壊的変更が行われている可能性があるため、Change Logを要確認
最新版でもReact 19に対応していない Githubなどでどのように対応されるかを確認する

よく使われていそうなライブラリで、特に対応が必要そうなもの

このセクションでは、他のNext.js プロダクトでもよく使いそうなライブラリのうちReact 19RCに対応していないものをピックアップし、リポジトリなどから抽出した対応方針・アップデートにおいての注意点を記載します。

  • @stripe/react-stripe-js
    • React 19への対応を求めるissueにて会話されているが、RC版であることから対応はまだ行わない方針のようです。
      以下コメントより引用。

      React 19 is still RC and not a stable release, so for now it's not supported/tested. Once that's release I expect we'll work towards updating that to allow 19.

    • メンテナによればReact 19RCにおいても確認されている問題はないとのことなので、--legacy-peer-depsオプションを付与してアップデートを行うことにしました。
      以下コメントより引用

      Currently, nothing is not working as expected.

  • next-auth
    • 4.29.9にて、Next 15の対応が行われた模様です。
      • Next.js 15対応のcommit
      • peerDependenciesにNext 15を加えるPR
        • React 19に対応していないというコメントがついているが特に返信はない。Next.js 15に対応しているため、legacy-peer-depsオプションで対応可能と判断しました。
  • shadcn/ui
    • 公式ドキュメントにて、Next.js 15 + React 19で利用するための手順や留意事項が記載されています。
      shadcn/uiで利用しているライブラリの対応状況
    • 特筆すべきはreact-day-pickerの対応状況です。
      • shadcn/uiでは現在react-day-pickerのv8系を利用していますが、v8系のpeerDependenciesには^16.8.0 || ^17.0.0 || ^18.0.0と記載されており、公式にはReact 19への対応が明記されていません。
        shadcn/uiのドキュメントでは、--legacy-peer-depsオプションを付与してアップデートを行うことで動作すると記載があり、v9系への対応も進められているようです。
      • なお、弊社ではexpansionであるdatetime-pickerを利用していました。このexpansionではreact-day-pickerのv9系への対応が先んじて行われていたため、弊社ではreact-day-pickerのv9系へのアップデートを行いました。
        手動で対応したい方はreact-day-pickerUpgrading to v9を参考にしてください。
  • @tiptapのextension
    • @tiptap/react本体はReact 19に対応しているがextensionの中にはReact 19に対応していないものが残っています。
    • 特にPro Extensionに関してはリポジトリが公開されていないため、対応状態が外から見て確認できません。
      • node_modulesから対象のパッケージのpackage.jsonindex.esm.jsを確認したところ、弊社で採用していたプラグインに関しては、React 19で行われた変更の影響を受けなかったため、--legacy-peer-depsオプションを付与してアップデートを行うことにしました。
    • また、tiptap上でTooltipなどを作る際に使うtippy.jsが、React 19に対応しないまま2024年11月9日にリポジトリがアーカイブされています。
      • 将来的に削除されるためReact 19から非推奨とされている、element.refへのアクセスを行っている部分が存在するため動作には問題がないものの、常に警告が表示される状態になっています。
      • tiptapチームでは、代替のFloating UIへの移行を進めているようですが、それまでにtippy.jsを利用している部分については僕が作成したフォークリポジトリの変更点を参考に変更してみて下さい。

3. React 19へのアップデート

ここまでくると、安心して--legacy-peer-depsオプションを付与したReact 19へのアップデートを行うことができます。

npm install --save-exact react@rc react-dom@rc --legacy-peer-deps  # --legacy-peer-depsオプションをつける

型定義もアップデートしておきましょう。

package.json
{
  "devDependencies": {
--      "@types/react": "18.3.11",
++      "@types/react": "npm:types-react@rc",
--      "@types/react-dom": "18.3.0",
++      "@types/react-dom": "npm:types-react-dom@rc"
  },
}

codemodでReact 19のBreaking Changesに対応する

npx codemod@latest react/19/migration-recipe

全てのcodemodを適用します。

? Press Enter to run the selected codemods in order. You can deselect 
anything you don’t want. (Press <space> to select, <a> to toggle all, <i> 
to invert selection, and <enter> to proceed)
❯◉ react/19/replace-reactdom-render
 ◉ react/19/replace-string-ref
 ◉ react/19/replace-act-import
 ◉ react/19/replace-use-form-state
 ◉ react/prop-types-typescript

4. Next.js 15へのアップデート

React 19へのアップデートが完了したので、次はNext.js 15への対応状況を確認します。

Reactと同様に以下のスクリプトを実行し、依存ライブラリがNext.js 15に対応しているかを調査しました。

スクリプトの内容
check-next-deps.js
const { execSync } = require("child_process");
const fs = require("fs");

const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8"));
const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies };

Object.entries(dependencies).forEach(([pkg, currentVersion]) => {
  try {
    // 現在のバージョンに関するpeerDependenciesを取得
    const currentPeerDepsOutput = execSync(`npm info ${pkg}@${currentVersion} peerDependencies --json`, { encoding: "utf-8" });
    const currentPeerDeps = JSON.parse(currentPeerDepsOutput || "{}");
    const currentNextJsSupport = currentPeerDeps.next || "No peerDependencies for Next.js";

    // 最新バージョンに関するpeerDependenciesを取得
    const latestPeerDepsOutput = execSync(`npm info ${pkg} peerDependencies --json`, { encoding: "utf-8" });
    const latestPeerDeps = JSON.parse(latestPeerDepsOutput || "{}");
    const latestNextJsSupport = latestPeerDeps.next || "No peerDependencies for Next.js";

    // 最新バージョン情報を取得
    const latestVersionOutput = execSync(`npm info ${pkg} version`, { encoding: "utf-8" });
    const latestVersion = latestVersionOutput.trim();

    // 結果を出力
    if (currentNextJsSupport !== "No peerDependencies for Next.js" || latestNextJsSupport !== "No peerDependencies for Next.js") {
      console.log(`${pkg}@${currentVersion} supports Next.js version: ${currentNextJsSupport}`);
      console.log(`The latest version ${pkg}@${latestVersion} supports Next.js version: ${latestNextJsSupport}\n`);
    }
  } catch (err) {
    console.error(`Error checking ${pkg}@${currentVersion}:`, err.message);
  }
});

このスクリプトを実行した結果の出力例は以下の通りです。

next-auth@4.24.10 supports Next.js version: ^12.2.5 || ^13 || ^14 || ^15
The latest version next-auth@4.24.10 supports Next.js version: ^12.2.5 || ^13 || ^14 || ^15

弊社のプロジェクトではNext.js 15に対応していないライブラリは見つからなかったため、そのままアップデートを行うことにしました。

--legacy-peer-depsオプションを付与してアップデートを行うため、以下のコマンドでライブラリのみをまずアップデートします。

npm install next@latest eslint-config-next@latest --legacy-peer-deps

Next 15のBreaking Changeに対応する

codemodで自動的に書き換え

npx @next/codemod@latest next-async-request-api .
npx @next/codemod@latest app-dir-runtime-config-experimental-edge .

弊社のプロダクトはCloud Runにデプロイしており、エッジコンピューティングやVercelへのデプロイは行なっていないため、app-dir-runtime-config-experimental-edgenext-request-geo-ipは実行していません。

codemodで自動的に書き換えされないエッジケース

一連の対応を行なった後、以下のエラーでnext buildに失敗しました。

src/app/xxx/page.tsx
Type error: Type 'XXXProps' does not satisfy the constraint 'PageProps'.
  Types of property 'params' are incompatible.
    Type '{ id: string; }' is missing the following properties from type 'Promise<any>': then, catch, finally, [Symbol.toStringTag]

一部のページコンポーネントについて、paramsPromiseでラップされていないというエラーです。
これはparamsを引数として取るものの、コンポーネント内で利用していないページコンポーネントに関しては、codemodで自動変換が行われないことが原因です。paramsを削除するかPromiseでラップするように変更しましょう。
ESLintのno-unused-varsルールを利用すると、このように不必要なparamsが存在しているとエラーが発生するため、このエラーを未然に防ぐことができます。

再現リポジトリ

この仕様が実装されたPR

キャッシュ周りの変更を確認

  • fetch関数がデフォルトでキャッシュを利用しなくなりました。
  • Route Handlersもキャッシュを利用しないようになりました。

弊社のプロダクトではServer Actions主体でバックエンドとのやり取りを行なっているので、fetch関数がそもそも少なくRoute Handlersも利用していないため、特に変更は必要ありませんでした。

Client-side Router Cacheへの対応

クライアント側でのキャッシュもデフォルトでoffに変更されました。
ユーザー側のページ遷移のUXを保つため、Next 14のときの設定と同じ設定値にしました。

next.config.mjs
++  experimental: {
++    staleTimes: {
++      dynamic: 0,
++      static: 300,
++    }
++  },

これにて、Next.js 15へのアップデート作業は完了です。

まとめ

今回は、弊社のプロダクトをNext.js 15にアップデートする際の手順、困ったポイントなどをまとめてみました。
同様のアップデートを予定されている方の参考になれば幸いです。

ストリーツ株式会社

Discussion