Skip to content
FrameworkJavaScript

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.

ViteAt a glance

Env file that wins
.env.[mode].local

First of 4 .env files, highest priority first. See the load order

Public prefix
VITE_

Only names with this prefix reach browser code, and anyone can read them there.

Values applied
At build

Values are read during the build and fixed in its output.

Built-in secret store
None: the shell environment and .env files at build time

vite.dev/guide/env-and-mode

Crunu command
crunu run --env storefront/production -- npm run buildRecipeCrunu has no dedicated integration here: this is the general setup.

Common pitfalls

  1. import.meta.env values are replaced in the bundle at build time. A value changed after vite build needs a new build.
  2. Only VITE_ variables reach client code. Every one of them ends up readable in the bundle, so none may hold a secret.
  3. Mode and NODE_ENV are separate: vite build --mode staging still runs with NODE_ENV set to production.
  4. .env files are read once when Vite starts. After editing one, restart the dev server.
  5. Bun preloads .env files into process.env, and Vite then treats those values as shell variables that outrank .env.[mode].
On this page

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, 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:

FileLoadedCommit it?
.env.[mode].localonly in that modeno, ignored by git
.env.[mode]only in that modeonly without secrets
.env.localin every modeno, ignored by git
.envin every modeonly 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.

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:

CommandNODE_ENVMode
vite buildproductionproduction
vite build --mode developmentproductiondevelopment
NODE_ENV=development vite builddevelopmentproduction

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 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 for the trade-offs.

Frequently asked questions

Something else? Write to support@crunu.com

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

Every source this page relies on, with the date we last checked it.

  1. Vite: Env variables and modes · checked October 30, 2026
  2. Bun: Environment variables · checked October 30, 2026

Keep your .env values in one place

Free during early access. Values are encrypted on your devices, each environment has its own key, and crunu run hands them to your app without writing a file.