この記事を読み終えると、「ローカルでは動くのにnpm run buildしたらundefined」「Nodeのサーバー側でimport.meta.envが読めない」「.envに書いた値が反映されない」という、Vite+Nodeの環境変数あるあるを、自分のプロジェクトで切り分けて直せるようになります。コピペで動く検証コードを2本置いておくので、まず手元で再現させてから読んでください。
Viteのimport.meta.envとNodeのprocess.envは別世界だと最初に腹をくくる
結論から断言します。Viteのクライアントコードでprocess.env.API_KEYを書いても、本番ビルドではほぼ確実にundefinedになります。理由は単純で、processはNodeのグローバルであり、ブラウザには存在しないからです。Viteはクライアント向けにimport.meta.envという別の入口を用意していて、しかもVITE_で始まる変数しか露出させません。
ここを混同したまま「dotenvをimportすれば直るはず」とvite.config.tsにあれこれ足して泥沼化するのが、初期セットアップで一番多い遭難ルートです。整理すると、登場人物は4つあります。
-
import.meta.env(Viteがクライアントに注入。VITE_プレフィックス必須) -
process.env(Node実行時のみ。サーバーコード・vite.config.ts内) -
loadEnv()(vite.config.tsの中で.envを手動で読むVite公式API) -
dotenvパッケージ(Node単体スクリプトやExpressサーバーが.envを読むため)
この4つの守備範囲が頭に入っていないと、どのファイルにどの書き方をすればいいか永遠に勘で当てることになります。
VITE_プレフィックスとビルド時インライン化で起きる「ハードコード事故」を再現する
Viteのimport.meta.env.VITE_XXXは、実行時に解決される変数ではありません。ビルド時に**文字列リテラルとして埋め込まれる(インライン化される)**のがポイントです。つまりdist/の中のJSファイルを開くと、VITE_付きの値がそのまま生のテキストで入っています。
これは「VITE_を付ければクライアントから見える」の裏返しで、VITE_を付けた秘密情報はビルド成果物に丸裸で焼き付くことを意味します。VITE_STRIPE_SECRET_KEYのような命名をして本番デプロイし、ブラウザのソースに秘密鍵が残っていた、という事故はここから生まれます。
まず挙動を体感するために、最小構成で「焼き付き」を観察します。プロジェクト直下に.envを置いてください。
# .env (プロジェクトルート)
VITE_API_BASE=https://api.example.com
SECRET_TOKEN=this-should-never-reach-the-browser
// src/env-check.ts (Viteクライアント側)
// VITE_ 付きは読める / 付いていない SECRET_TOKEN は undefined になる
const report = {
apiBase: import.meta.env.VITE_API_BASE, // => "https://api.example.com"
secret: import.meta.env.SECRET_TOKEN, // => undefined(プレフィックス無し)
legacyProcess: typeof process, // => "undefined"(ブラウザにprocessは無い)
mode: import.meta.env.MODE, // => "development" or "production"
isProd: import.meta.env.PROD, // => boolean
};
console.table(report);
// 実証ポイント:build後に検証する
// 1) npm run build
// 2) grep -r "api.example.com" dist/ → ヒットする(インライン化されている証拠)
// 3) grep -r "this-should-never-reach" dist/ → ヒットしない(VITE_無しは注入されない)
export default report;
npm run build後にdist/をgrepして、VITE_API_BASEの値が生で入っていることと、SECRET_TOKENが一切含まれないことを自分の目で確認してください。この一手間を踏むだけで、「秘密はVITE_を付けない」「公開してよい設定だけVITE_」という線引きが体に入ります。
loadEnvを使わずにvite.config.tsでprocess.envを読もうとすると詰む話
意外と知られていませんが、vite.config.tsの中のprocess.envには、.envファイルの値は自動では入っていません。vite.config.tsはNodeで評価されますが、Viteが.envを読むのは設定評価より後のタイミングであり、しかも読んだ結果をprocess.envに丸投げするわけではないからです。
なので「設定ファイルでポート番号を.envから出し分けたい」「baseやproxy先を環境で変えたい」というとき、process.env.VITE_PORTを直接参照するとundefinedで、開発サーバーが意図しないポートで立ち上がります。正解はVite公式のloadEnvを使うことです。第3引数を''にするとVITE_プレフィックス以外も含めて全部読めます。
// vite.config.ts
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
// 第3引数 '' で VITE_ 以外(PORT等)も読み込む
const env = loadEnv(mode, process.cwd(), '');
// .env に PORT=5180 / VITE_API_BASE=... と書いておけば下が効く
const port = Number(env.PORT) || 5173;
return {
server: {
port,
proxy: {
// サーバー秘密はここで使う:クライアントに焼き付かない
'/api': {
target: env.API_ORIGIN || 'http://localhost:3000',
changeOrigin: true,
},
},
},
// 必要なら独自プレフィックスを許可(既定は ['VITE_'])
envPrefix: ['VITE_', 'PUBLIC_'],
};
});
API_ORIGINのようなプレフィックス無しの値は、proxy.target(=ビルドに焼き付かないサーバー設定)でだけ使う。これがViteで「秘密をクライアントに漏らさず環境で出し分ける」王道パターンです。envPrefixを足せばPUBLIC_など自分の命名規則も使えますが、増やすほど露出範囲も広がるので、安易にenvPrefix: ''で全公開にしないこと。
.env.localと.env.productionの読み込み優先順位を勘違いして本番URLが上書きされる
Viteは複数の.env系ファイルを読みますが、優先順位が直感とズレています。よくある事故は、開発時に作った.env.localがGit管理外なのを忘れ、本番ビルド(mode=production)でも読まれて、VITE_API_BASEがローカルホストのまま本番に出てしまうケースです。
読み込み順(後勝ち)は概ねこうです。
-
.env(全モード共通・最弱) -
.env.local(全モード共通・Git無視が推奨。.envより強い) -
.env.[mode](例:.env.production。モード限定) -
.env.[mode].local(例:.env.production.local。最強)
ここで罠なのは、.env.localは**.env.productionより読み込みは先でも、モード固有ファイルである.env.productionの方が後に評価されて勝つ**点です(同名キーの場合)。つまり「本番値は必ず.env.productionに書く」「ローカル専用の上書きは.env.local」と役割を分ければ事故りません。逆に.env.localに本番想定の値を置くと、開発でもCIでも勝手に効いてしまい、原因究明に半日溶かします。MODEをenv-check.tsで出力させ、ビルドのモードを毎回確認する癖を付けてください。
TypeScriptでimport.meta.envがanyになり補完が効かない問題をvite-env.d.tsで潰す
TypeScriptプロジェクトだと、import.meta.env.VITE_API_BASEが型補完されずany扱いになり、タイポしても気づけないという地味な落とし穴があります。src/vite-env.d.tsに型を宣言すると、未定義の変数名を書いた瞬間に赤線が出るようになります。
// src/vite-env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE: string;
readonly PUBLIC_FEATURE_FLAG?: 'on' | 'off';
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
これでimport.meta.env.VITE_API_BAES(タイポ)が型エラーになり、undefinedをビルド成果物に埋め込む前に止められます。VITE_変数は注入されないと静かにundefinedになり実行時まで気づけないため、型で先回りする価値が大きいです。
Node側(Express等)はdotenvが必要、Viteのフロントとは設定を分けて二重管理しない
フロントをVite、APIをExpressのような構成だと、サーバーはimport.meta.envを持ちません。サーバーは素直にdotenv(Node 20.6以降ならnode --env-file=.envでも可)でprocess.envに読み込みます。
ここでありがちな失敗が、フロントとサーバーで.envを2つに分けたのに片方の更新を忘れて、changeOrigin先のポートだけ古い値が残る、という不整合です。対策は「公開してよい値はVITE_付きで1つの.envに集約し、サーバー秘密だけ.envの非プレフィックス領域に置く」。vite.config.tsはloadEnvで同じファイルを読み、サーバーはdotenvで同じファイルを読む。読み手は分けても、.envという真実のソースは1つにするのがメンテの最短路です。
Node 20.6以降を使っているなら、dotenvを入れずにこう起動できます。
# package.json の scripts 例
# "dev:server": "node --env-file=.env server.js"
# 起動時に .env が process.env へ読み込まれる(dotenv不要)
node --env-file=.env server.js
5分で切り分けるチートシート(保存版)
手が止まったら上から順に確認してください。
- クライアントで
undefined→ 変数名がVITE_始まりか。.envを編集したらdevサーバーを再起動したか(.env変更は自動リロードされないことがある)。 - ビルド後だけ壊れる →
process.envをクライアントで使っていないか。import.meta.envへ置換。 - 本番URLがローカルのまま →
.env.localが勝っていないか。本番値は.env.productionへ。 -
vite.config.tsで値が読めない →process.envではなくloadEnv(mode, process.cwd(), '')を使う。 - 秘密鍵がブラウザに見える →
VITE_を外し、proxyやサーバー側process.envでのみ使用。dist/をgrepして再確認。 - 型補完が効かない →
vite-env.d.tsにImportMetaEnvを宣言。
Viteの環境変数は「ビルド時インライン化」と「VITE_プレフィックスによる露出制御」の2点さえ腹落ちすれば、ほとんどの初期トラブルは10分で切り分けられます。まずはenv-check.tsを貼ってnpm run buildし、dist/をgrepする——この一往復が、勘デバッグから抜け出す一番の近道です。
補足:環境構築でローカルマシンやクラウド課金が増えがちな人は、固定費の見直し(格安SIM・光回線)や、開発の合間に始めるネット証券の新NISA口座開設など、生活側の自動化も同時に進めると消耗が減ります。手元のセットアップが片付いたら、そちらの「設定一回で効く系」も棚卸ししてみてください。