Next.jsでNEXT_PUBLICプレフィクスの環境変数をできるだけ避ける

あけましておめでとうございます。もう2025年とは早いですね。まだ2024年くらいの気持ちです。不思議ですね。

年が明けてやりたいことといえば今年の目標を立てる、という方が多いかなと思います。僕はそういう人間ではないというか、目標を立てても三日坊主とは言わないまでも1ヶ月くらいで目標とはなんだったのか、となってしまうことが多いので、近年は目標は意識していません。 そんな僕でも今年は意識していきたいことが一つあります。それは「Next.jsでNEXT_PUBLIC_のプレフィクスをつけた環境変数は乱用しない」です。同じような目標を立ててる方もきっと多いことでしょう。ということでその話を書いておきます。あとここからは「ですます調」ではなくなります。

Next.jsの環境変数

このエントリではNext.js 15 + App Routerについて書いている。環境変数については、まあ基本はここに書いてある通り。

nextjs.org

何かしらの設定を入れたいときに環境変数を利用したいというのはまあ当然だと思う。Node.jsだと環境変数process.env.FOO_ENVというように参照できるが、client componentでこれをやるとHydration Errorが発生する。具体的には以下のコード

'use client'

export default function Client1() {
  const apiUrl = process.env.API_URL;

  return (
    <>
      <div>
        <p>client</p>
        <p>API_URL: {apiUrl}</p>
      </div>
    </>
  );
}

考えてみるとこれは当然で、process.env.FOO_ENVはサーバーサイドでは環境変数を参照できるけど、クライアントサイド(つまりブラウザ)では参照できない。そのため、サーバーサイドとクライアントサイドで状態不一致となってしまうため、エラーとなる(と理解している)。

NEXT_PUBLIC_というprefixをつけた環境変数は、build時に静的に置換することでこの問題を解決する。

nextjs.org

問題

わかる人にはもうわかると思うけど、build時に解決されてしまうので、ランタイムで値を差し替えることができない。つまり、NEXT_PUBLIC_環境変数を利用する場合、値の内容によってはひとつの成果物を複数環境で利用できなくなってしまう。これは困る。自分達はNext.jsのstandalone buildで生成したものを含んだDockerイメージを作り、それをECSだったりk8sだったりで動かすことが多いが、build時に静的に埋め込まれてしまうとなると、単一のイメージを使えなくなってしまう。でも俺たちは単一のイメージを使いたいんや!!!

ということでNEXT_PUBLIC_環境変数を諦めることとする。そのためには「環境ごとに変わる環境変数」をどうやってロードするかを解決する必要がある。

解決方法

解決方法といいつつ、実は自信を持ってこれだ、という解決策は持っていない。基本はサーバーサイドで環境変数を読み、それを何らかの方法でクライアントに渡す、というやり方になる。

コンポーネントからクライアントコンポーネントに渡す

素朴だけど、以下のようにprops経由で渡す。要するに環境変数が参照できるところで参照して、その値を渡していく。

  • Client Component
'use client'

export default function Client1({apiUrl}: {apiUrl: string}) {

  return (
    <>
      <div>
        <p>client1</p>
        <p>API_URL: {apiUrl}</p>
      </div>
    </>
  );
}
  • Server Component
export default async function Home() {
  await connection()
  const apiUrl = process.env.API_URL!;
  return (
    <>
      <Client1 apiUrl={apiUrl}/>
    </>
  );
}

Server Actionsを使う

設定値を取得するだけのServer Actionsを用意して、それをクライアントコンポーネントから呼び出す。環境変数自体はサーバーサイドでロードする。

'use server'

export async function getApiUrlFromEnv() {
   return process.env.API_URL
}
'use client'

import { getApiUrlFromEnv } from "@/app/actions";
import { useEffect, useState } from "react";

export default function Client1() {
  const [apiUrl, setApiUrl] = useState<string | null>(null)

  useEffect(() => {
    getApiUrlFromEnv().then((url) => {
      setApiUrl(url || '')
    })
  }, [])

  return (
    <>
      <div>
        <p>client2</p>
        <p>API_URL: {apiUrl}</p>
      </div>
    </>
  );
}

正直どちらの方法も一長一短あると思う。個人的には前者のほうが好きだけど、コンポーネント間で設定を引き回し続けるということになり、コードの見栄えは悪くなると感じる。ある程度整理はできそうだけど。後者は設定を読むだけでリクエストが一度増えるのが気になるけど、大したリクエストでもないしあまり気にしなくてもよいかも。

もっといい方法ありそうだなとは思っているので。よさそうなやり方を知っている方がいれば教えて欲しいです。

ということで

昨年これを何度もレビューで指摘されて、そうだったそうだったとなっていたので、今年はそうならないようにしたいですね。

唐突なんですが、このエントリは はてなエンジニア Advent Calendar 2024 32日目です。2025年の目標を書いたのに2024年です。だからまだ2024年の気持ちだったのかもしれませんね。ちなみにもう少し続くらしいです。去年も「もう少し続くんじゃ」みたいなことを書いた気がします。なんにしろ今年もよろしくお願いします。