💡

Create React Appで作成したReactアプリケーションをNext.jsに移行した話

2022/07/03に公開

概要

私が開発に関わっているReactのフロントアプリケーションを、Create React App(以下CRA)実装から、Next.jsに移行したので、どのような段取りで移行し、どこに注意点があったかについてまとめました。

前提知識

アプリケーションの概要

あるSaaSサービスのアプリケーションで、ざっくりいうとサービスは、フロントエンドアプリケーションと、APIのエンドポイントを提供するバックエンドのアプリケーションに分かれていて、フロントエンドはCRAを用いて構築されたシンプルなSPAページです。
ただしSaaSサービスなので、ユーザー認証が必須で、SPAでページを遷移する度にブラウザから権限チェックのためのAPIにアクセスをし、権限があればそのページを表示、なければログインページにリダイレクトするという作りになっています。

移行前のアーキテクチャのポイント

  • CRAのreact-scriptsでビルドしたHTML/JS/CSSをnginxで静的に配布
    • 要件として現段階ではSSRにする必要性が薄く、ペライチのHTMLだけをクライアントに返して、あとはブラウザ側で動的にデータを取得するシンプルな構成を取っていました。
  • ルーティング部分はreact-routerを使用
  • それ以外のReactコンポーネント等のアーキテクチャについてはこの記事では触れません

そもそもなぜCRAからNext.jsに移行する必要があったのか

CRAはすでにメンテナンスモードに移行しているということで、簡単な保守は継続されても大規模なアップデートはおそらく今後見込めない状況になっています。
ejectして自力で周辺パッケージのバージョンをあげるなどの保守は可能ではありますが、そこにコストをかける必要性は低く、できれば依然としてno configで提供されるビルドツールに乗っかっていきたいです。

Next.jsを選ぶ理由

  • no configのまま移行できるし、必要に応じて設定ファイル(webpack.config等)を拡張することもできるから
  • インターネットの海に十分に知見が転がっているので、2022年の技術選択としてベターであると判断したから
  • SSGやSSRに対応できるから
    • 現状はプロダクトとして必要ないが今後拡張が容易に行える
  • その他諸々の最適化が施されているから

移行に伴うアーキテクチャの選定

Next.jsはご存知の通りSSG/SSRのpre-renderingをサポートしているため、CRAとは異なりNode.jsのプロセスを動かしてHTMLをレンダリングすることがフレームワークレベルで可能になります。
さらにAPIルーターの機能も含まれているため、やりようによってはオールインワンの構成にすることも可能ですが、実際に求められる要件とコストのバランスから適切なアーキテクチャを選定しました。

現行の構成

- backend
    - expressのサーバーでAPIのエンドポイントをルーティング
- frontend
    - CRAが出力した成果物(HTML/JS/CSS)をnginxで静的に配布
        - 開発時のみnode環境で実行

空SSG+SPAな構成案

- backend
    - expressのサーバーでAPIのエンドポイントをルーティング
- frontend
    - Next.jsが出力した成果物(HTML/JS/CSS)をnginxで静的に配布
        - 開発時のみnodeで実行
    - ただしHTMLの中身は空で、ブラウザ側で動的にデータをfetchする

ポイント

  • インフラの構成を変えずに移行できるため手始めとしては最も適切。
  • SSGで空のHTMLを生成した上でブラウザ側で動的にデータを取得するという構成を維持
    • ※SSGは本来ビルド時に必要な情報を焼き付けてHTMLを生成するために使用するが、ここでデータ取得をせずに空のHTMLを返すだけ

SSRを使用する構成案

- backend
    - expressのサーバーでAPIのエンドポイントをルーティング
- frontend
    - nodeのプロセスを動かしページをpre-renderingできるようにする

ポイント

  • SSRでpre-renderingすることで表示の高速化が期待できる。ただしWebサーバーがnginxからNode.jsに移行、かつ、レンダリングプロセスを動かすことになるので、インフラ構成に手を入れる&それなりのCPUパワーも必要になる
  • 画像の配布最適化などのNext.jsがサポートするサーバサイドのAPIを使用することも可能
  • ただし各ページの表示権限をチェックするために、ブラウザのCookieに保存したセッション情報をつけてブラウザから権限チェック用のAPIにリクエストをしている関係上、コードをそのままサーバサイドに持っていっても実行できるようなアーキテクチャにはなっておらず、ある程度大きめなコードの改修をする必要がある

APIルーティングもNext.jsに寄せる構成案

- Nextjs
	- ページルーティングとAPIルーティングを全部nextレイヤに載せる

ポイント

backend/frontendという概念がなくなり、全部Next.js上に乗っかりよりNext.jsっぽい構成に寄せることになる。ただしbackendのアーキテクチャはDDDでかっちり作ってあるため、Nextの世界に寄せるためにはアーキテクチャを全面的に見直す必要があり、正直この案はやりすぎ。ただし今後新たなプロダクトを作るとなった時に規模次第で選択肢には入る。

空SSG+SPAな構成案を採用

まずはアーキテクチャに大きく手を入れずに移行することを最優先するため、今回は空SSG+SPAな構成を選択しました。
prerenderingというNext.jsの大きな旨味の恩恵には授かれていないのですが、それ以外にもビルドの最適化の恩恵に授かれることと、今後サービスの要件や展望次第でアーキテクチャを刷新する場合の選択肢が広がるという効果は十分にあったかなと思っています。

空SSG+SPAな構成 にするためには

今回Node.jsのプロセスを起動せずにスタンドアローンなアプリケーションを構築するために、next exportという仕組みを採用しました。
next exportを実行すると予めビルドされたHTML/JS/CSSが出力されるため、あとはこれらをnginxで配布するだけで、やりたい構成が実現できます。
ただしSSRや通常のSSGとは異なり、ランタイムでNode.jsのプロセスが動かないため、一部の機能が使えなくなるという制約があります。
なおnext exportはその前段階にnext buildが必須であるため、package.jsonに以下のようにnext buildとセットで実行するためのscriptsを記述しています。

  "scripts": {
    "build": "next build",
    "export": "yarn build && next export"
  }

また、ローカル開発時はnext devを使うことで、HMRでの起動が可能になります。
CRA時代にも同様にreacts-scripts startでHMRでの起動が可能でしたが、react-script startとの違いが大きく2つあります。

ブラウザが自動で開かなくなった

"start": "cross-env PORT=3000 react-scripts start"
のように設定していると、起動時にブラウザが自動で起動するようになっていたのですが、next devではそのような仕組みがなくなりました。
そこでNext.jsのissueでのやりとりを参考に
"dev": "open-cli http://localhost:3000 && next dev"
のようにopen-cliで該当のページを起動するという方法を採用しました。
なお、open-cliを使用しているのはMac/Windows両環境で動作させるためです。

TypeScriptの型チェックが行われなくなった

このアプリケーションはTypeScriptで記述されていますが、reacts-scripts start時代は型チェックも一緒に走っていたため、型のエラーがコンソールに表示されるようになっていました。
ただnext devでは型チェックが走らず、next buildしてみて初めて型エラーがあることに気がつくという状態になっており、DeveloperExperience的にあまり適切ではない状態になってしまいました。
そこでこちらもissueでのやりとりを参考にして、tscを同時に走らせるという方法を採用しました。

これら2点のカスタマイズをまとめると以下のようにscriptsは落ち着きました。

"scripts": {
 "ts": "tsc --noEmit --preserveWatchOutput --pretty","
  "dev": "open-cli http://localhost:3000 && concurrently -n NEXT,TS -c magenta,cyan \"next dev -p 3000\" \"yarn ts --watch\"",
}

移行に伴うポイント集

アーキテクチャの大枠について記述してきましたが、ここからは具体的な移行手順やポイントについて触れていきます。

package.jsonにnextを依存に追加。react-scriptsreact-router/react-route-domの依存を消す

基本的な知識ではありますが、Next.jsはファイルシステムルーティングという仕組みを採用しており、ディレクトリ構成がそのままページのルーティングの構成として表現されます。そこで元々react-routerで行っていたルーティング処理の移行も同時に行いました。

環境変数のprefixを REACT_APP_から NEXT_PUBLIC_ に変える

REACT_APP_ はreact-scripts時代に、.env下の環境変数をブラウザ側で実行するjsでも参照できるように、webpackでjsファイルの中に焼き込むために必要としていたprefixでしたが、Next.jsでも同様の仕組みがサポートされており、専用のprefixを付与します。

なお、どうやらprocess.env.NEXT_PUBLIC_(*)となっているコードを、webpack時に静的に環境変数の中身で書き換えているらしく

const env = (key: string): string => {
  const value = process.env[key];
}
const some = env("NEXT_PUBLIC_SOME_ENV");

上のように動的にprocess.envの値を参照することができませんでした。
(CRA時代はこのような書き方でも参照できていた)

今までpublic/index.html として全ページで共通のHTMLの要素を定義していたものを、src/pages/_app.tsxとsrc/page/_document.tsx に移動

_app.tsxと _document.tsx は共通のレイアウトなどを置くためにNext.jsであらかじめ予約されているファイルになります。
ここは各ページ共通のコンポーネントとなるため、ロジックを置きまくると、ページごとにJSを分割した時に無駄なコードが入り込む可能性があるため、注意しながら移行しました。

windowやdocumentオブジェクトを参照するファイルがnode環境でimportされないように、ひたすらdynamic importでSSR時は無効にする

まず今回採用したSSGの手法ですが、これはあらかじめnodeの環境でビルドを行い、静的にHTMLやJSやCSSを生成 する手法になります。それはすなわち全てのモジュールがNode.jsの環境でも動作することが前提になります。
一方で、pages下からimportされるファイルの中でwindowやdocumentオブジェクトへの参照を持っているコードが入り込むと、当然Node環境では存在しないグローバルオブジェクトであるためビルドエラーになってしまいます。
この問題を避けるために最も単純な案は、 typeof window ≠= “undefined” のような条件を入れて回避することですが、そもそも今回移行したアプリケーションはブラウザ環境で動く前提で作られているため、こういった分岐を一つでも入れると、その先で本来不必要な分岐がコード内にあちらこちらに伝播していくことになってしまいます。
そこでアプローチを変え、dynamic importを使用して、windowやdocument参照するコードをNode.js環境でimportしないようにしました。
これにより既存の処理に手を入れずに移行を進めることができました。

まとめ

このようなアプローチで移行を進め、大きな問題もなく無事ユーザーリリースができました。
目に見える大きな成果として、移行前後でビルド時間が50%程削減するという効果も得られました。
Next.jsを使用するにあたって同じようなアーキテクチャを選択する事例はそれほど多くないとは思いますが、何かの参考になれば幸いです。

参考文献

https://zenn.dev/oliver/articles/ooparts-to-next-from-cra
https://nextjs.org/docs/migrating/from-create-react-app

Discussion