TypeScript

Fix: import.meta.env Undefined on Production Build (Vite)

Vite strips anything not prefixed VITE_ out of the client bundle on purpose — here is how to confirm that's the cause and the three ways to fix it.

Mahdi Ben RhoumaMahdi Ben Rhouma
6 min read

TL;DR#

import.meta.env.VITE_SOMETHING returning undefined after vite build almost always means the variable in your .env file isn't prefixed with VITE_. Vite only exposes prefixed variables to client code by design — it's not a bug, and it's the same behaviour in dev, it just tends to go unnoticed there because of how the value is read.

The error#

A React app built with Vite reads its API URL from an environment variable that works during vite dev but comes back empty once deployed:

config.ts
javascript
const url = import.meta.env.VITE_SERVER_URL; // undefined after build

This is one of the most common Vite questions on Stack Overflow — opening the compiled bundle in DevTools shows the whole import.meta.env object present, but the specific key you need is missing or empty. The original question's own code reads three such variables — VITE_SERVER_URL, VITE_API_ENDPOINT and VITE_AUTH_ENDPOINT — through import.meta.env, plus import.meta.env.MODE to branch between production and development, and logs all three with console.log(url, api, auth) for debugging. All three read correctly during vite dev; only the built, served output loses them, and Vite's build step raises no warning or error that would point at the cause.

Why it happens#

Vite's own documentation states the rule directly: "Variables prefixed with VITE_ will be exposed in client-side source code after Vite bundling." Any other environment variable — API_KEY, SERVER_URL, DATABASE_URL — is deliberately left out of import.meta.env in the built bundle. This isn't an oversight: it stops a .env file that also holds server-side secrets from leaking straight into a JavaScript bundle every visitor can read.

Three specific mistakes reproduce the same symptom:

  1. Missing the VITE_ prefix. SERVER_URL=https://api.example.com in .env is invisible to import.meta.env; only VITE_SERVER_URL=... is exposed.
  2. The .env file isn't in the project root. Vite looks for .env files relative to its environment directory, which defaults to the project root — a file placed inside src/ is never read.
  3. Reading process.env instead of import.meta.env in code that runs in the browser. Some older tutorials and CRA-era code use process.env.*. Vite doesn't polyfill process.env for client code the way Create React App did, so that pattern silently returns undefined for anything that isn't separately defined.

The gap between dev and production in the original question came from a fourth cause: manually assigning process.env = { ...process.env, ...loadEnv(mode, process.cwd()) } inside vite.config.ts. That line patches Node's process.env while Vite's own config is evaluating — it has no effect on what ends up in import.meta.env inside the bundled client code, so it can appear to "work" in some dev scenarios where the config file itself reads process.env, while the actual app code reading import.meta.env never sees those values in the built output.

Fix#

1. Rename the variable with the VITE_ prefix (the default fix)#

.env
bash
# before
SERVER_URL=https://api.example.com
 
# after
VITE_SERVER_URL=https://api.example.com
config.ts
javascript
const url = import.meta.env.VITE_SERVER_URL;

Rebuild — no vite.config changes needed. This covers the large majority of reports of this error.

2. Or set a custom prefix with envPrefix#

If renaming every variable isn't practical, Vite lets you change which prefix it looks for:

vite.config.ts
javascript
import { defineConfig } from 'vite';
 
export default defineConfig({
  envPrefix: 'APP_', // now APP_* is exposed instead of VITE_*
});

Vite refuses an empty string for envPrefix outright, precisely because that would expose every environment variable — including ones that were never meant to reach the browser — to client code.

3. Confirm the .env file location and Vite's build mode#

Make sure the file sits at the project root next to vite.config.ts, not inside src/, and matches the mode Vite is building for:

text
.env                # loaded in all modes
.env.production     # loaded only for `vite build`
.env.development     # loaded only for `vite dev`

If you need to explicitly load env variables inside vite.config.ts itself — for example to inject a value via define, rather than relying on the automatic import.meta.env exposure — use loadEnv with its third parameter to bypass the prefix filter for that one use case:

vite.config.ts
javascript
import { defineConfig, loadEnv } from 'vite';
 
export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), ''); // '' = ignore prefix, config-time only
  return {
    define: {
      __APP_VERSION__: JSON.stringify(env.npm_package_version),
    },
  };
});

This pattern is for values the config file needs at build time — it still does not put unprefixed variables into import.meta.env for your application code, which is a separate, intentional restriction.

4. Stop reading process.env in browser code#

If your code reads process.env.VITE_SERVER_URL instead of import.meta.env.VITE_SERVER_URL — common in a project migrated from Create React App, which polyfilled process.env for client code — Vite won't fill in the same way. Vite's client bundle has no process.env polyfill by default, so any reference to it in browser-run code returns undefined regardless of prefix, build mode, or .env file location. Search the codebase for process.env.VITE_ and replace every match with import.meta.env.VITE_ — this is a mechanical find-and-replace, not a config change.

Verify the fix#

Build and preview the production bundle locally rather than trusting dev mode alone, since dev and build can behave differently for the reasons above:

bash
npm run build
npm run preview

Open the app, check the network tab or a console.log(import.meta.env) temporarily added to your entry file, and confirm the VITE_-prefixed key now holds the expected value — not undefined. If you're debugging inside a CI pipeline rather than locally, also check the CI job's own logs for the vite build step: a missing .env.production file in the CI checkout (as opposed to a developer's machine, where it might exist untracked) reproduces the exact same symptom, and is easy to miss if you only ever tested the build locally where the file was present all along.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

Docker changes where the .env file needs to live#

If the variable is correctly prefixed but still comes back empty inside a Docker-built image specifically, the usual cause is different: the .env file wasn't copied into the build context before vite build ran, or the variable was meant to be injected as a build argument (ARG / ENV in the Dockerfile) rather than read from a file that only exists on the host machine. That's a build-context problem, not a Vite prefix problem, and needs the value passed explicitly at image build time instead.

A related but different Vite failure — the build breaking earlier, before import.meta.env is even relevant, because index.html was moved into src/ under a custom root config and Rollup can no longer resolve the entry script's path — is covered in Fix Rollup failed to resolve import "/src/main.tsx" in Vite. If the failure happens even earlier still, before Vite gets to your config at all, see Fix Cannot find module '@vitejs/plugin-react' in Vite. If the project pairs Vite with Next.js-style environment handling instead (a mixed setup, or a migration in progress), the equivalent Next.js pitfall is documented in Next.js Environment Variables Undefined on Vercel? Fix — the two frameworks expose environment variables through different mechanisms, so a fix for one does not carry over to the other.

Once the prefix rule clicks, this stops being a recurring surprise: anything your client bundle needs to read gets VITE_, anything it must never see stays unprefixed and server-side only.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote