In Vite, a variable that starts with VITE_ is public browser configuration, not a secret. Vite exposes it through import.meta.env and bundles it into client-side code at build time, so use VITE_ for values like API base URLs, keep real secrets on the server, and only use loadEnv() when .env values need to shape vite.config.*.
That one rule prevents most Vite env bugs: missing production URLs, staging builds pointed at localhost, and API keys accidentally shipped to every visitor.
The Safe Mental Model
Split your values into two buckets:
Public browser config:
VITE_API_URL=https://api.example.com
VITE_APP_TITLE=Dashboard
Private server secrets:
DB_PASSWORD=...
STRIPE_SECRET_KEY=...
OPENAI_API_KEY=...
The public bucket can be read by browser code:
const apiUrl = import.meta.env.VITE_API_URL;
The private bucket should not be read by browser code. If client JavaScript needs something that requires a secret, put that call behind your own backend route, serverless function, or edge function.
If your app uses a local Vite dev proxy, remember that it is a development convenience, not a production secret boundary. I have a separate write-up on why Vite's proxy only works in dev, and the same rule applies here: production needs an actual server-side layer.
What Vite Exposes By Default
Vite always provides a few built-in values:
if (import.meta.env.DEV) {
console.log("Running locally");
}
console.log(import.meta.env.MODE);
console.log(import.meta.env.BASE_URL);
The common ones are:
import.meta.env.MODE
import.meta.env.BASE_URL
import.meta.env.PROD
import.meta.env.DEV
import.meta.env.SSR
Your own variables need the configured client prefix. By default, that prefix is VITE_.
VITE_API_URL=https://api.example.com
DB_PASSWORD=do-not-ship-this
console.log(import.meta.env.VITE_API_URL); // available
console.log(import.meta.env.DB_PASSWORD); // undefined
That undefined result is a feature. Vite is trying to keep unprefixed values out of the client bundle.
Use Mode Files Deliberately
Vite can load several env files:
.env
.env.local
.env.production
.env.production.local
.env.staging
.env.staging.local
The useful pattern is:
.env
shared defaults that are safe to commit
.env.local
local machine overrides, ignored by git
.env.production
production public config
.env.staging
staging public config
For example:
# .env
VITE_APP_TITLE=My App
# .env.staging
VITE_API_URL=https://staging-api.example.com
Then build for staging:
vite build --mode staging
Mode-specific files take priority over generic files. Existing environment variables from the shell or deployment provider have the highest priority, so check your host settings when a production build refuses to use the value in your repo.
If you are cleaning up the structure of a Vite app, this is a good moment to decide where env examples, deployment notes, and app config helpers live. That fits naturally beside a sane Vite folder structure, especially once a project has more than one environment.
Remember That Everything Is A String
Vite exposes env variables as strings. Convert them at the edge of your app instead of scattering conversion logic everywhere.
VITE_ENABLE_ANALYTICS=true
VITE_MAX_UPLOAD_MB=10
export const appConfig = {
apiUrl: import.meta.env.VITE_API_URL,
analyticsEnabled: import.meta.env.VITE_ENABLE_ANALYTICS === "true",
maxUploadMb: Number(import.meta.env.VITE_MAX_UPLOAD_MB || 10),
};
For anything required, fail early:
function requiredEnv(name, value) {
if (!value) {
throw new Error(`Missing required env variable: ${name}`);
}
return value;
}
export const appConfig = {
apiUrl: requiredEnv("VITE_API_URL", import.meta.env.VITE_API_URL),
};
That gives you a loud development or build-time failure instead of a broken production fetch to undefined/users.
Add TypeScript Hints
If you use TypeScript, add explicit env names to vite-env.d.ts.
interface ViteTypeOptions {
strictImportMetaEnv: unknown;
}
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_APP_TITLE: string;
readonly VITE_ENABLE_ANALYTICS?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
Keep that file declaration-only. Vite's docs call out that imports can break the augmentation.
This does not validate runtime values. It keeps your editor and compiler honest about the variable names you meant to use.
Use loadEnv() Only In Vite Config
Most app code should use import.meta.env. Sometimes the env value needs to shape Vite itself, such as a dev server port, a plugin toggle, or a compile-time constant.
That is when loadEnv() belongs in vite.config.js or vite.config.ts.
import { defineConfig, loadEnv } from "vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
return {
server: {
port: env.APP_PORT ? Number(env.APP_PORT) : 5173,
},
define: {
__APP_ENV__: JSON.stringify(env.APP_ENV),
},
};
});
Vite does not automatically load .env* files into process.env while the config file is being evaluated. That is why the helper exists.
If you want a deeper config walkthrough, start with how to configure your Vite config file before turning env logic into a custom plugin or a tangle of conditionals.
The third argument matters. Passing "" tells Vite to load all env variables regardless of prefix. That is fine inside config when you are careful, but do not dump the whole env object into define.
Be Careful With envPrefix
Vite lets you customize the public prefix with envPrefix.
import { defineConfig } from "vite";
export default defineConfig({
envPrefix: ["VITE_", "PUBLIC_"],
});
Use this sparingly. The prefix means "this may be exposed to browser code." It does not mean "this is more secure."
Vite rejects envPrefix: "" because that would expose everything and leak sensitive values. That guardrail is useful, but it is not a substitute for naming discipline.
A Practical Checklist
Before shipping, check this:
- Every value read from browser code uses
import.meta.env.
- Every client-exposed custom variable starts with
VITE_ or your configured public prefix.
- No private API key, database password, signing secret, or service token starts with that public prefix.
.env.local and .env.*.local are ignored by git.
- Staging and production builds use the right
--mode.
- Required values fail early in a small config helper.
- Numeric and boolean values are converted from strings.
- Anything that needs a secret goes through a backend route, edge function, or production API gateway.
Also restart the Vite dev server after changing .env files. Vite loads those files at startup, so editing .env while the dev server is already running is a classic way to chase a fake bug.
The Short Version
Treat Vite env variables as build-time wiring. VITE_ is for public browser configuration, not secrets. Use mode files to separate environments, use loadEnv() only when the Vite config itself needs the values, and keep secret work behind server-side code.
That pattern is boring. That is the point. Env handling should not be clever enough to leak your production keys.
Sources