# Vite env variables: VITE_ prefix and .env modes

> How Vite loads .env files per mode, why only VITE_ variables reach client code, and how to build a Vite app with values from Crunu instead of .env files.

URL: https://crunu.com/environment-variables/vite
Language: en
Updated: 2026-10-30

Vite reads variables from the shell and from `.env`, `.env.local`, `.env.[mode]` and `.env.[mode].local`, then exposes the ones that start with `VITE_` to your code as `import.meta.env.VITE_*`. During `vite build` those references are replaced with literal strings, so a Vite app's values are fixed at build time and every `VITE_` value is readable by anyone who loads the page. Variables already present in the shell beat every file. These rules come from the [Vite guide on env variables and modes](https://vite.dev/guide/env-and-mode), which describes Vite 8 as of October 30, 2026.

## Files, modes and who wins

Vite loads four files from the project root, or from the directory set by `envDir`:

| File                | Loaded            | Commit it?           |
| ------------------- | ----------------- | -------------------- |
| `.env.[mode].local` | only in that mode | no, ignored by git   |
| `.env.[mode]`       | only in that mode | only without secrets |
| `.env.local`        | in every mode     | no, ignored by git   |
| `.env`              | in every mode     | only without secrets |

A file for a specific mode takes priority over a generic one, and the `.local` variants are meant for values that never leave your machine. Above all of them sits the environment Vite was started in: a variable that exists when Vite runs is never overwritten by any file. `VITE_SOME_KEY=123 vite build` wins over every `.env*` file.

*Figure: Diagram of how Vite loads environment variables. Env files are read in this order of priority, and the first one wins: .env.\[mode].local, .env.\[mode], .env.local, and .env. Values are copied into the build output, so a new value needs a new build. Variables that start with VITE\_ go into the browser bundle and are visible to every visitor; server and build code can read every variable.*

The mode is `development` for the dev server and `production` for `vite build`, unless you pass `--mode`. A `staging` mode would load `.env.staging` and `.env.staging.local`.

## Mode is not NODE_ENV

People often expect `vite build --mode staging` to produce a non-production build. It doesn't change `NODE_ENV`, which stays `production`:

| Command                           | `NODE_ENV`  | Mode        |
| --------------------------------- | ----------- | ----------- |
| `vite build`                      | production  | production  |
| `vite build --mode development`   | production  | development |
| `NODE_ENV=development vite build` | development | production  |

`import.meta.env.PROD` follows `NODE_ENV`, while `import.meta.env.MODE` follows `--mode`. If staging needs different API endpoints, read them from `VITE_` variables in `.env.staging` and branch on `MODE`, not on `PROD`.

## What actually lands in the bundle

Vite exposes these values through `import.meta.env`:

- the built-in constants `MODE`, `BASE_URL`, `PROD`, `DEV` and `SSR`;
- every variable whose name starts with `VITE_`, or with the prefix you set in `envPrefix`.

In development they're defined as globals. In `vite build` they're statically replaced, which lets the bundler drop dead branches. The result is that `import.meta.env.VITE_API_URL` becomes the string `"https://api.example.com"` in the output file.

The Vite guide is blunt about the consequence: `VITE_*` variables should not contain sensitive information such as API keys. A variable without the prefix, such as `DB_PASSWORD`, comes back as `undefined` in client code, which is the protection. Renaming it to `VITE_DB_PASSWORD` to "make it work" publishes it.

Two smaller details:

- **Everything is a string.** `VITE_RETRIES=3` arrives as `"3"`, and `VITE_DEBUG=false` as the non-empty string `"false"`. Convert explicitly.
- **Variables expand.** Vite runs dotenv-expand, so `$OTHER` inside a value is replaced. Escape a literal dollar sign as `\$`.

## Before you add a VITE\_ variable

Because the prefix is the only thing between a value and the public bundle, it's worth a short check every time someone adds one:

- **Would you put this value in a public web page?** If not, it doesn't get the prefix. The browser should call an endpoint you control, which holds the real key.
- **Is it different per stage?** Then it belongs in `.env.[mode]` or in the build environment, not hard-coded, and each stage needs its own build.
- **Does anyone change it often?** Every change means a rebuild and a redeploy, so a frequently changing value is better fetched at runtime from your API.
- **Is the name listed in `.env.example`?** New teammates and CI need to know the variable exists, even though the value lives elsewhere.

If you changed `envPrefix`, apply the same check to the new prefix. A broad prefix exposes more variables than you expect, and anything that matches it goes into the bundle.

## Restarts, Bun and other surprises

`.env` files are read when Vite starts. Editing `.env.local` while `vite` is running changes nothing until you restart it.

If you run Vite through Bun, Bun loads `.env` files into `process.env` before Vite starts. Vite respects existing `process.env` values, so the values Bun loaded look like shell variables and outrank Vite's own mode files. The Vite guide flags this, and the [Bun page](https://crunu.com/environment-variables/bun) shows how to turn Bun's loading off.

The config file is evaluated before Vite loads env files, so `vite.config` can't simply read `.env.production` through `process.env`. Setting `NODE_ENV` on the command line lets Vite detect it early.

## Build a Vite app with values from Crunu

Because a Vite app's values are fixed during the build, the place to inject them is the build command. `crunu run` decrypts an environment in memory and starts the build with its variables in the process environment, where Vite gives them top priority over any `.env` file. Each stage gets its own Crunu environment and its own key, so a developer who builds against `storefront/development` never holds the production values.

1. Put the `VITE_` variables for each stage into a Crunu environment such as `storefront/development` and `storefront/production`, and tick **Load as environment variables (crunu run)** on the file.
2. Develop with `crunu run --env storefront/development -- npm run dev` after signing in once with `crunu login`.
3. In CI, give a service token access to the production environment only and build with `exec crunu run --env storefront/production -- npm run build`.
4. Deploy the `dist` folder as usual. The values are already inside it.

Keep the values honest about what they are. Everything in a Crunu environment that your Vite build reads with the `VITE_` prefix becomes public the moment the site is deployed. Crunu protects the values on the way to the build and controls who can change them. It can't make a public variable private. Put real secrets in a separate environment that only your backend reads.

`--restart on-change` doesn't help here either: a built Vite site is a folder of static files. To change a value, run the build again.

## When this setup is worth it

For a small static site with one public analytics ID, a committed `.env.production` is fine because nothing in it is secret. Crunu pays off when the same repository builds for several stages, when build-time values include things you don't want in Git history, such as a token that uploads source maps to an error tracker, or when people come and go and you need to know who could read which values. See [how to share .env files with your team](https://crunu.com/guides/share-env-files-with-a-team) for the trade-offs.

## Related reading

- [Next.js](https://crunu.com/environment-variables/nextjs) inlines `NEXT_PUBLIC_` the same way, but can also read server values per request.
- [SvelteKit](https://crunu.com/environment-variables/sveltekit) and [Astro](https://crunu.com/environment-variables/astro) build on Vite's loader and add their own rules.
- [What a public env variable is](https://crunu.com/learn/public-env-variable) and [what a .env file is](https://crunu.com/learn/dotenv-file).
- [crunu run](https://crunu.com/docs/cli/run) and [a service token in any CI](https://crunu.com/docs/ci/any-ci-with-a-service-token).

## FAQ

### Why is import.meta.env.MY_KEY undefined in my Vite app?

Vite exposes only variables that start with VITE_ to client code, unless you changed envPrefix. Rename the variable, or better, keep it on a server if it's a secret. Also restart the dev server after editing a .env file.

### Does a variable in my shell override the one in .env.production?

Yes. Variables that already exist when Vite runs have the highest priority and are never overwritten by .env files. That's also why values passed by crunu run win.

### How do I keep an API key out of a Vite app?

Don't give it the VITE_ prefix. Call the third-party API from a backend, a serverless function or an edge function that holds the key, and let the browser call that instead.

### Will crunu run --restart on-change update a deployed Vite site?

No. A built Vite app is static files with the values already in them. Rebuild and redeploy to change them. Restarting helps only programs that read their environment while running.

## Sources

- [Vite: Env variables and modes](https://vite.dev/guide/env-and-mode) (accessed 2026-10-30)
- [Bun: Environment variables](https://bun.com/docs/runtime/environment-variables) (accessed 2026-10-30)
