本番運用中の NextJS アプリケーションを、重量級の Node.js standalone ランタイムから軽量なアーキテクチャへ移行しようと試みた。目的は、NextJS の output: 'export' を用いて静的アセットを生成し、それらを同じ Docker イメージにバンドルした軽量なカスタム Go サーバーで配信することだった。
そのアプリは、8 つのロケールをサポートする複雑な国際化対応サイトだった。主要要件はユニバーサルビルドを維持することだった。つまり、再ビルドなしで各ステージにデプロイできるよう、Docker イメージは環境変数に依存しない(アグノスティックである)必要があった。主目的は、重量級の NextJS ランタイムによる RAM 消費を取り除くこと。NodeJS/Bun のランタイムは、standalone モードで ~ 200-450MB の RAM、静的エクスポートで ~140MB を消費していた。
ユニバーサルビルドを実現するため、ビルド工程から環境変数を取り除いた。API エンドポイントを静的フロントエンドに焼き込む代わりに、/api/graphql のような静的パスを用いた。これらのリクエストの処理はバンドルした Go サーバーに任せ、実行時の環境変数で定義された適切なバックエンドサービスへプロキシとして転送させた。
画像最適化も同様に NextJS ランタイムから切り離した。専用の CDN パスへ書き換えるカスタム画像ローダーを実装し、静的エクスポートが画像処理ロジックに依存しないようにした。
スラッグでアクセスするブログ記事のような動的ルートの扱いに大きな障害があった。NextJS の Static Export では、ビルド時にすべてのパスを定義するため generateStaticParams が必要になる。あらゆるブログ記事を事前レンダリングする非効率を避けるため、動的ルートセグメントの代わりにクエリパラメータを使うようアーキテクチャを変更した。これにより、すべてのコンテンツ要求に対して汎用の静的 HTML ファイルを 1 つだけ配信し、クライアント側アプリがその後に個別のデータで埋める方式にできた。
このプロジェクトは最終的に、SEO(検索エンジン最適化)、とりわけカノニカル URL メタデータの要件に関する解決不能な衝突のために断念した。
多くのメタデータは静的のままでも大きな影響はないが、カノニカルタグは正しく機能させるためにドメインを含む絶対 URL が必要だ。NextJS の app ディレクトリでは、メタデータは generateMetadata または静的な metadata エクスポートで定義する必要があり、静的エクスポートではどちらもビルド時にしか実行されない。Node.js サーバーなしに、これを実行時へ委ねる組み込みの仕組みはない。したがって、ユニバーサルな静的エクスポートではビルド時にドメイン名を知ることができない。
これを提案したアーキテクチャ内で正すには、Go サーバーが静的 HTML を解析し、実行時にカノニカルタグ文字列内のドメイン、またはドメイン様のプレースホルダー値を動的に置換する必要がある。この方法は、実質的に静的エクスポートに対して Go ベースのテンプレーティングを実装することを要求するため、却下した。そのような改変はエレガントではなく、サーバー送信の HTML を変更した結果、クライアント側の React アプリと不一致を起こすハイドレーション問題という重大なリスクを伴う。
SEO の順位が不要なら、マルチ環境の Docker イメージでも軽量ランタイムは実現できる。そうでなければ、静的な .html と .js 内のプレースホルダー ENV 変数をカスタムのテンプレートエンジンで置換する、といったかなりハック的な手段でしか実現できない。
BACKEND_URL、API_ENDPOINT のような動的な環境変数やカスタムリライトルールが必要な NextJS アプリでは、軽量なカスタム Golang ベースのサーバーを使える。簡単で、実際に動く(~13 MB の RAM 使用量)。機能ごとに実装できるワークアラウンドも多い。
SEO が重要でないサイトであれば、静的エクスポートをホストできるサーバーはいくらでもある。たとえば私のプロジェクトのひとつでは、Rust-based static-web-server を使っている(~8 MB の RAM 使用量)。
今回の試みを数値で見ると次のとおり:
もしあらゆる手段を尽くしてでも望む結果を得ようとするなら、更新にはおおよそ 挿入行が 4× 増える ことと、~100 ファイル にわたる変更が必要だった。
