🏔️

Next.js の App routerで環境変数を利用するときにハマったこと

2023/12/07に公開

Next.js の App Routerで環境変数を利用するときに困ったこと

株式会社マネーフォワード福岡拠点でフロントエンドエンジニアをしている廣池です。
この記事は Money Forward Fukuoka Advent Calendar 2023 の7日目の記事として投稿しています。

この記事ではNext.js13で登場したApp Routerモードで環境変数を利用する場合にハマったポイントや暫定的な対処法を紹介しようと思います。

Next.jsで環境変数を利用する方法

まず初めにNext.jsで環境変数を扱うための方法をざっくりと説明します。
公式サイトの Environment Variables に記載のある通り .env ファイルにデフォルトで対応しており、development環境などに合わせenvファイルを切り替えることも可能です。

envファイルで指定したパラメータはNext.jsが自動的に読み込み・マージを行いコード上では process.env を経由して取得が可能です。

環境変数をただ画面上に出すだけのシンプルなものをベースに見てみましょう。

TEST_DATA=testData
export default function Page() {
    return (<h1>Server Component: data is "{process.env.TEST_DATA}"</h1>)
  }

上記のpageを閲覧するとTEST_DATAを表示してくれます。

上記のコードをClient Componentとして実行した場合はHydration Errorが出ます。

'use client';

export default function Page() {
    return (<h1>Client Component: data is "{process.env.TEST_DATA}"</h1>)
  }

これはClient Componentのコードが処理されるのはクライアント側でその際に参照されるprocessはブラウザーのprocessになってしまうため、データが取得できないためです。
Client Component内で利用したい場合は公式サイトのBundling Environment Variables for the Browser にも記載がある通り、対象のパラメータ名に NEXT_PUBLIC のprefixを付ける必要があります。

NEXT_PUBLIC_TEST_DATA=publicData
'use client';

export default function Page() {
    return (<h1>Client Component: data is "{process.env.NEXT_PUBLIC_TEST_DATA}"</h1>)
  }

ではアプリケーションをbuildするとアプリケーション上の挙動はどうなるでしょう。

next build + next startの場合

Client Component

NEXT_PUBLIC のprefixをつけたパラメータはbuild時に解決され、インラインでbuildの出力として以下のようにstaticファイルに吐き出されます。
これについても公式サイトのStatic Rendering (Default)にて記載があります。

~(省略)~ return(0,n.jsxs)("h1",{children:["Client Component: data is ","testData"]}) ~(省略)~

また、prefixがない場合は特に書き換えずに出力されます。ただし process.env を参照するのはブラウザの環境なので当然値を取得できず、何も表示されません。 next dev で実行したときと違ってエラーにもなりません。

~(省略)~ return(0,n.jsxs)("h1",{children:["Client Component: data is ",o.env.TEST_DATA]}) ~(省略)~

ブラウザで見ると、このように環境変数の値が表示されません。

Server Component

では use client を指定せずServerComponentとして出力した場合はどうなるでしょうか

NEXT_PUBLIC のprefixがついたデータは変わらずinlineで出力され固定値になります。

~(省略)~ return(0,s.jsxs)("h1",{children:["Server Component: data is ","testData"]}) ~(省略)~

ちなみに出力されてる場所が static/chunks から server/app 配下になっていて挙動が違っているのも見て取れます。

prefixがない場合はClient Componentと同様です。

~(省略)~ return(0,s.jsxs)("h1",{children:["Server Component: data is ",process.env.TEST_DATA]}) ~(省略)~

ただし、対象のComponentを処理する環境はブラウザからnodeになるため環境変数の値を取得することができます。

ではここで以下のように対象の環境変数を直接変更して画面上の値が変化するのか確認してみましょう

TEST_DATA=OverridenData pnpm run start

なんと、値が変わっていません。

なぜこうなってしまうかというとNext.jsのページキャッシュが関連してきます。

ページキャッシュについて

Next.jsでは様々方法でレンダリングに関する効率化を行っています。その中の一つにページキャッシュがあります。
ページキャッシュは各ページを一定の条件で動的なページか静的なページかを判断し、静的なページについてはbuildのタイミングで解決してより効率的なレンダリングを行います。
今回は何もしないシンプルなcompnentを実装したため静的なページと判断されbuild時にhtmlが生成されてしまうため、実行時には環境変数を読み込まれないという結果になってしまいます。

実際 .next フォルダに展開されるファイルを見てみるとindex.htmlというのが生成されています。
これの中身を見てみるとinlineで値が入ってるのが確認できます。

~(省略)~ {\"children\":[\"Server Component: data is \",\"testData\"]} ~(省略)~

動的なページと判断される条件は Opting out of Data Caching に載っています。
ページごとに常にruntime上で生成したい場合は dynamicforce-dynamic を指定します。


export const dynamic = 'force-dynamic';

export default function Page() {
    return (<h1>Server Component: data is {process.env.TEST_DATA}</h1>)
}

実際に再度以下のコマンドで動作を試してみましょう。

TEST_DATA=OverridenData pnpm run start

無事環境変数を読み取れました!

またDocker imageを共通化させながら環境変数を使う方法として公式サイトのRuntime Environment Variablesで各Componentで noStore を設定して対応することもアナウンスされています。

ちなみにビルドしたときのコンソールログで対象のページが動的・静的どちらの扱いとして生成されたか確認することができます。

Route (app)                              Size     First Load JS
┌ λ /                                    140 B            84 kB
└ ○ /_not-found                          877 B          84.7 kB
+ First Load JS shared by all            83.8 kB
  ├ chunks/569-6961324a6798b7a2.js       28.7 kB
  ├ chunks/c141e8ea-9e027de7199c3c94.js  53.3 kB
  ├ chunks/main-app-c282768a11960bae.js  219 B
  └ chunks/webpack-bf1a64d1eafd2816.js   1.61 kB


○  (Static)   prerendered as static content
λ  (Dynamic)  server-rendered on demand using Node.js

採用した方針

私達のチームでは結論としてキャッシュを利用せずdynamicなページとして定義することで常にnode上で実行されるようにしました。ポイントになったのは以下の点です。

  1. サービスとしてキャッシュが効かせにくい
    toBのサービスの特性上、すべてのユーザーに同じ画面を提供することは殆どありません。そのためキャッシュ自体が難しく常にruntimeで生成してもそこまで大きなコストにはならないだろう。という判断をしました。

  2. Docker Imageのポータビリティ性を維持したかった
    現在開発を進めているサービスはDockerを利用してアプリケーションを配信する予定です。imageを環境ごとにbuildして対応する方針も考えましたがimageのポータビリティ性が低くなってしまうこと、別のimageでテストすることによる統合テストで得られる信頼が下がってしまう点等を考慮して環境ごとにbuildを分けることはしていません。

ただ、NEXT_PUBLIC のprefixを利用せずに実装する場合、クライアントで利用するパラメータとサーバーのみで利用するパラメータを区別が難しくなります。prefixがあればNext.js側でうまくハンドリングできますがサーバーで実行する以上仕組みで閉じることは難しいです。
そこで運用で回避するため命名でどのように使われ方をするか明記して、レビューでチェックするようにしています。具体的には以下のようなルールにしています。

  • NEXT_PUBLIC_XXXX: mockの有無などbuild時に固定されても良い情報
  • PUBLIC_XXXX: 各環境ごとに可変でクライアントに渡して良い情報
  • それ以外: 各環境ごとに可変でサーバーでのみ利用する情報

最後に

色々パターン分けをして調べたり、他の手段がないか試した内容をざっくり整理してまとめてみました。
実はこのあたりの実装を行っていた頃には公式サイトにdynamicを利用して行う方法の記載がなく試行錯誤していたのである程度役に立つかなと思いまとめていました。
ですがこの記事をまとめるにあたって公式サイトを確認していたところ、最近になって追記されていたので実態はほとんど公式を読んでください。で終わっちゃう内容になってしまいました... 泣

ですが個人的にはServer ComponentやNext.jsのキャッシュ戦略などは開発を始める前はあまりキャッチアップできていなかったのでいい勉強になったかなと思っています。
これからApp Routerを使う人にちょっとでも参考になると嬉しいです。

Discussion