DEV Community

スシロー
スシロー

Posted on

Vite + Node .env Cheat Sheet 2026: Why import.meta.env Is undefined (and 6 Fixes That Actually Work)

Read this and you'll be able to: load the same .env into both a Vite frontend and a Node/Express backend without two copies, stop import.meta.env.VITE_API_URL from silently becoming undefined in production, and prove which variables actually shipped to the browser using a 5-line build check. Every snippet below runs as-is on Vite 5/6 and Node 20+.

The reason .env wrecks half a day isn't that it's hard — it's that Vite and Node read environment variables through two completely different mechanisms that look identical in code. Once you see the split, the bugs become obvious.

The split nobody tells you: Vite's import.meta.env is a build-time string swap, Node's process.env is a runtime lookup

Here is the single fact that fixes 80% of these issues:

  • Node reads process.env.FOO when the process starts. Change the value, restart, done.
  • Vite does not read env at runtime. During vite build, it does a literal find-and-replace: every import.meta.env.VITE_FOO token in your source is textually substituted with the string value, then minified. There is no process and no lookup in the shipped bundle — the value is baked into the JS.

That's why these two failures are so common:

  1. You set API_URL=... (no prefix) and import.meta.env.API_URL is undefined — Vite only exposes variables prefixed with VITE_ to client code, on purpose, so you don't leak your database password into a public bundle.
  2. You set VITE_API_URL in your deploy dashboard (Vercel/Render) but it was already built — the substitution happened at build time, so a runtime env var set afterward changes nothing.

If you remember only one sentence: for the browser, env vars are frozen at vite build; for Node, they're live at boot.

One .env, two readers: sharing variables between Vite and Express with loadEnv

The duplication trap is keeping frontend/.env and backend/.env in sync by hand. You don't have to. Vite ships loadEnv, which reads .env files the same way the dev server does, and you can call it from vite.config.js to forward selected vars into Node-land or to define non-prefixed values explicitly.

Here's a working vite.config.js that loads a shared root .env, validates that required keys exist, and fails the build loudly instead of shipping undefined:

// vite.config.js  (Vite 5/6, Node 20+)
import { defineConfig, loadEnv } from 'vite'

export default defineConfig(({ mode }) => {
  // 3rd arg '' = load ALL vars, not just VITE_-prefixed ones.
  // process.cwd() points at the project root where .env lives.
  const env = loadEnv(mode, process.cwd(), '')

  const required = ['VITE_API_URL', 'VITE_SENTRY_DSN']
  const missing = required.filter((k) => !env[k])
  if (missing.length) {
    throw new Error(
      `\n[env] Missing required keys for mode "${mode}": ${missing.join(', ')}\n` +
        `Add them to .env or .env.${mode} before building.\n`
    )
  }

  return {
    define: {
      // Explicitly forward a NON-prefixed value to the client.
      // JSON.stringify is mandatory — define does a raw text paste,
      // so a bare string becomes an undefined identifier and crashes.
      __BUILD_MODE__: JSON.stringify(mode),
    },
  }
})
Enter fullscreen mode Exit fullscreen mode

The JSON.stringify detail is the classic define footgun. define pastes the value as raw source. If you write __BUILD_MODE__: mode, the bundle ends up with const x = production — an undeclared identifier — and you get ReferenceError: production is not defined at runtime, not build time. Wrap every define value in JSON.stringify.

The production-only undefined: when import.meta.env.VITE_API_URL works in dev and dies in Docker

This is the bug that eats an afternoon, because it never reproduces locally. Sequence that triggers it:

  1. Locally, npm run dev reads .env live — everything works.
  2. In CI you run vite build before the deploy step injects environment variables. The VITE_API_URL token gets substituted with the empty string.
  3. The container runs node server.js serving the static dist/, and you set VITE_API_URL on the running container — which does nothing, because the value was already baked.

The tell: your network tab shows requests going to https:///api/users — note the missing host, because undefined or '' was concatenated into the URL. To catch this before users do, grep the built bundle. Variables that shipped will appear as literal strings; ones that didn't won't.

Here's a runnable post-build verifier — drop it in as a postbuild script:

// scripts/check-env-baked.mjs
// Run AFTER `vite build`. Verifies that required VITE_ vars were
// actually substituted into the bundle, not left as empty strings.
import { readFile, readdir } from 'node:fs/promises'
import { join } from 'node:path'

const DIST = 'dist/assets'
const MUST_APPEAR = [
  process.env.VITE_API_URL,      // the literal value we expect baked in
]

const files = await readdir(DIST)
const jsFiles = files.filter((f) => f.endsWith('.js'))
let blob = ''
for (const f of jsFiles) {
  blob += await readFile(join(DIST, f), 'utf8')
}

const missing = MUST_APPEAR.filter((val) => val && !blob.includes(val))
if (missing.length) {
  console.error('[check-env] These values never made it into the bundle:')
  for (const m of missing) console.error('  -', m)
  console.error('Cause: build ran before env vars were set. Order matters.')
  process.exit(1)
}
console.log(`[check-env] OK — ${MUST_APPEAR.filter(Boolean).length} value(s) baked into ${jsFiles.length} JS file(s).`)
Enter fullscreen mode Exit fullscreen mode
// package.json
{
  "scripts": {
    "build": "vite build",
    "postbuild": "node scripts/check-env-baked.mjs"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now a build that ran before its env vars were available exits non-zero in CI instead of shipping a broken host. The fix for the underlying ordering problem is one line: make sure your CI sets VITE_* in the same step (or an earlier step) than vite build, never after.

The .env load-order trap in Node: .env.local quietly overrides .env.production

Vite (and dotenv-style loaders) resolve env files with a fixed priority, and people lose hours not knowing the ranking. From highest to lowest priority in Vite:

  1. .env.[mode].local — e.g. .env.production.local
  2. .env.[mode] — e.g. .env.production
  3. .env.local — loaded in every mode except test
  4. .env

The sharp edge: .env.local (rank 3) outranks .env.production? No — re-read: .env.production (rank 2) beats .env.local (rank 3). But .env.production.local beats everything. The real-world failure is a developer who once put VITE_API_URL=http://localhost:3000 in .env.local for convenience, then months later a production build keeps pointing at localhost because .env.production.local was generated by a script and inherited the stale value. Rule of thumb: commit .env and .env.[mode]; gitignore every *.local file; never put environment-specific hosts in .env.local.

A matching .gitignore that prevents the most dangerous leak — committing a populated .env.local:

# Commit these (defaults / non-secret):
# .env
# .env.production

# Never commit local overrides or secrets:
.env.local
.env.*.local
Enter fullscreen mode Exit fullscreen mode

Node-only secrets: keep DATABASE_URL out of the browser with the VITE_ boundary

The VITE_ prefix isn't a style convention — it's a security boundary. Anything without the prefix is invisible to client code, which is exactly what you want for DATABASE_URL, STRIPE_SECRET_KEY, or an OpenAI/Anthropic API key. In your Express backend, read those with plain process.env and they never touch the bundle:

// server.js — runs in Node, reads live process.env
import 'dotenv/config'   // loads .env into process.env at boot
import express from 'express'

const app = express()

// Non-prefixed = server-only. Never reachable from import.meta.env.
const DB_URL = process.env.DATABASE_URL
if (!DB_URL) {
  throw new Error('[server] DATABASE_URL missing — refusing to start.')
}

app.get('/api/health', (_req, res) => {
  // Expose only what's safe; never echo the secret itself.
  res.json({ ok: true, dbConfigured: Boolean(DB_URL) })
})

app.listen(3000, () => console.log('API on :3000'))
Enter fullscreen mode Exit fullscreen mode

The failure mode here is the inverse of the frontend one: a teammate prefixes a secret as VITE_STRIPE_SECRET_KEY "to make it work," and now your Stripe secret is a plain string sitting in the public JS bundle, greppable by anyone who opens DevTools. The check-env-baked.mjs script above doubles as an audit — run it looking for the prefix of a secret name and fail the build if a secret ever appears in dist/.

The 30-second decision table for which file gets which variable

When you're staring at a variable wondering where it goes, answer two questions:

  • Does the browser need it? If yes → it must start with VITE_, and accept that it's public. If no → no prefix, read it only via process.env in Node.
  • When is it read? Browser values are frozen at vite build (set them in the build step's environment). Node values are read at process start (set them wherever the server boots).

Map of who-reads-what:

You write Browser sees it? Read with
VITE_API_URL Yes (baked at build) import.meta.env.VITE_API_URL
DATABASE_URL No process.env.DATABASE_URL
__BUILD_MODE__ via define Yes (raw paste) global __BUILD_MODE__

If you internalize the build-time-freeze vs runtime-lookup split, set required-key validation in vite.config.js, and gate CI with the bundle-grep check, the .env afternoon-killer turns into a one-minute setup. The variables stop being mysterious because you now know exactly when each one is read and who is allowed to see it.

Quick recap of the six fixes: (1) prefix browser vars with VITE_; (2) JSON.stringify every define value; (3) set VITE_* before vite build, never after; (4) verify with a post-build bundle grep that exits non-zero; (5) gitignore *.local and keep hosts out of .env.local; (6) keep secrets unprefixed so they never reach the client.

Top comments (0)