diff --git a/.changeset/browser-envname-fallback.md b/.changeset/browser-envname-fallback.md
new file mode 100644
index 0000000..b9a5049
--- /dev/null
+++ b/.changeset/browser-envname-fallback.md
@@ -0,0 +1,7 @@
+---
+"@vlandoss/env": patch
+---
+
+Clarify that the `envConfig()` Vite plugin is required for **any** non-development browser build, not just custom modes.
+
+In the browser, `envName()`'s `readEnv()` only reads `window.__env` — never `process.env` — so a pure SPA can't see `NODE_ENV` / `VITE_ENV`. Without the plugin's `__ENV_NAME__` inject, `envName()` falls back to `"development"` (silently shipping the dev config to every environment), regardless of the `--mode` or `VITE_ENV` used at build time. The docs and the `__ENV_NAME__` JSDoc previously stated the browser would fall back to `"production"`, which is only true on the server.
diff --git a/docsite/content/docs/concepts/env-name.mdx b/docsite/content/docs/concepts/env-name.mdx
index 16515bf..775d428 100644
--- a/docsite/content/docs/concepts/env-name.mdx
+++ b/docsite/content/docs/concepts/env-name.mdx
@@ -11,7 +11,7 @@ icon: Hash
First defined wins:
1. **`env.ENV`** — explicit runtime override (always wins). Use this in CI, tests, or scripts.
-2. **`__ENV_NAME__`** — build-time literal injected by the `envConfig()` Vite plugin (the env it built for: `VITE_ENV` if set, otherwise Vite's `mode`). Beats `NODE_ENV` because Vite forces `NODE_ENV="production"` regardless of `--mode`, so this is the only way `envName()` can return custom envs like `staging` / `qa` in the browser.
+2. **`__ENV_NAME__`** — build-time literal injected by the `envConfig()` Vite plugin (the env it built for: `VITE_ENV` if set, otherwise Vite's `mode`). In a browser build this is the **only** source that carries the built env name into your code — `readEnv()` reads `window.__env`, never `process.env`, so without it `envName()` can't see `NODE_ENV` / `VITE_ENV` at all (see [the Vite gotcha](#the-vite-gotcha)). Placed above `NODE_ENV` so a build-time env name wins even over the `NODE_ENV="production"` that Vite forces regardless of `--mode`.
3. **`env.NODE_ENV`**
4. **`env.VITE_ENV`**
5. **`"development"`** — fallback.
@@ -28,10 +28,10 @@ envName({ NODE_ENV: "test", ENV: "qa" }); // -> "qa"
## The Vite gotcha
-If you run `vite build --mode staging`, Vite **still sets `NODE_ENV="production"`** at build time. Without the `envConfig()` plugin, `envName()` in browser code would return `"production"` — not `"staging"`.
+In the browser, `envName()`'s `readEnv()` only sees `window.__env` (or the `` tag) — **never `process.env` or `import.meta.env`**. So in a pure SPA, none of `NODE_ENV` / `VITE_ENV` reaches `envName()`: with no plugin and no `__ENV_NAME__`, every entry in the chain is empty and `envName()` falls through to its `"development"` fallback — *regardless* of the `--mode` or `VITE_ENV` you built with. A `vite build --mode production` that worked locally will quietly ship the **development** config.
-The plugin solves this by injecting `__ENV_NAME__ = "staging"` as a build-time constant (from `--mode staging` or `VITE_ENV=staging`). That constant wins over `NODE_ENV` in the precedence chain, so the browser sees the env you actually built.
+The plugin solves this by injecting `__ENV_NAME__` as a build-time constant (from `VITE_ENV`, else Vite's `mode`). That constant is the only thing `envName()` can read in the browser, so it sees the env you actually built.
-This is why **the plugin is required for custom Vite modes**, even if you load config via dynamic import (Pattern 1) and don't actually use the `#config` alias.
+This is why **the plugin is required for any non-development browser build** — even if you load config via dynamic import (Pattern 1) and don't actually use the `#config` alias.
See [Guides → SPA dynamic import](/docs/guides/spa-dynamic-import) and [Guides → Custom modes](/docs/guides/custom-modes) for the wiring.
diff --git a/docsite/content/docs/guides/custom-modes.mdx b/docsite/content/docs/guides/custom-modes.mdx
index b8c1c4d..3da13a7 100644
--- a/docsite/content/docs/guides/custom-modes.mdx
+++ b/docsite/content/docs/guides/custom-modes.mdx
@@ -6,7 +6,7 @@ icon: GitBranch
## The problem
-You run `vite build --mode staging`. Vite **forces `NODE_ENV="production"`** at build time, regardless of `--mode`. So in browser code, the default precedence of `envName()` returns `"production"` — not `"staging"`.
+You run `vite build --mode staging` and expect `envName()` to say `"staging"` in the browser. It won't — and the reason is subtle. In the browser, `envName()`'s `readEnv()` only reads `window.__env` (or the `` tag), **never `process.env`**. A pure SPA has neither, so `NODE_ENV` and `VITE_ENV` are invisible and `envName()` falls through to its `"development"` fallback — no matter what `--mode` you built with. (Vite even forces `NODE_ENV="production"` at build time, but that only ever reaches `envName()` on the _server_, not in browser code.)
This is independent of how you load config: it bites both [Pattern 1 (dynamic import)](/docs/guides/spa-dynamic-import) and any browser code that calls `envName()` directly.
diff --git a/docsite/content/docs/guides/spa-dynamic-import.mdx b/docsite/content/docs/guides/spa-dynamic-import.mdx
index 9a2d95d..c389bef 100644
--- a/docsite/content/docs/guides/spa-dynamic-import.mdx
+++ b/docsite/content/docs/guides/spa-dynamic-import.mdx
@@ -69,7 +69,7 @@ export default defineConfig({
- **Dynamic `import()` in `defineEnv`**: Vite sees the template literal and emits one chunk per matching file under `../config/`. `defineEnv` auto-unwraps the ESM module namespace (no manual `.default`).
- **`await defineEnv(...)`**: when `config` is a Promise, `defineEnv` returns `Promise>`. You can also skip the `await` and let consumers handle the Promise.
-- **`envConfig()` plugin**: in this pattern you don't import `#config`, so why bring the plugin? Because it also injects `__ENV_NAME__` at build time, which is **the only way `envName()` can return non-`production` values in the browser** — Vite forces `NODE_ENV="production"` regardless of `--mode`. Without the plugin, `envName()` in browser code would always say `"production"` after a build. See [`envName()`](/docs/concepts/env-name) and [Custom modes](/docs/guides/custom-modes).
+- **`envConfig()` plugin**: in this pattern you don't import `#config`, so why bring the plugin? Because it also injects `__ENV_NAME__` at build time, and that constant is **the only way the built env name reaches `envName()` in the browser** — `readEnv()` reads `window.__env`, never `process.env`, so a pure SPA can't see `NODE_ENV` / `VITE_ENV`. Without the plugin, `envName()` falls back to `"development"` after a build — silently shipping your dev config to every environment — no matter what `--mode` or `VITE_ENV` you built with. See [`envName()`](/docs/concepts/env-name) and [Custom modes](/docs/guides/custom-modes).
## Hardening chunk filenames
diff --git a/package/src/lib/const.ts b/package/src/lib/const.ts
index 7528b17..679a98a 100644
--- a/package/src/lib/const.ts
+++ b/package/src/lib/const.ts
@@ -30,7 +30,11 @@ export const ENV_GLOBAL_ID = "__env";
* return the right env name in the browser without manual `runtimeEnv` plumbing.
*
* Sits **between** `env.ENV` (explicit runtime override) and `env.NODE_ENV` in
- * `envName()`'s precedence chain — required for non-default modes (`staging`,
- * `qa`, …) because Vite forces `NODE_ENV="production"` regardless of `--mode`.
+ * `envName()`'s precedence chain. In the browser it's the only source `envName()`
+ * has: `readEnv()` reads `window.__env`, never `process.env`, so a pure SPA sees
+ * no `NODE_ENV` / `VITE_ENV` and falls back to `"development"` without this inject.
+ * That's why the plugin is required for any non-development browser build — not
+ * just custom modes like `staging` / `qa` (which Vite would otherwise flatten by
+ * forcing `NODE_ENV="production"` regardless of `--mode`).
*/
export const BUILD_TIME_ENV_NAME_ID = "__ENV_NAME__";