Repository: wxt-dev/wxt (2026-04-14T13:51:30Z)

Issue:
`.env` file is no longer auto-imported
### Describe the bug

After updating `wxt` from `0.20.20` to `0.20.21`, this error is caused:

> Error: Missing required config: CHROME_EXTENSION_ID, FIREFOX_EXTENSION_ID

I have these environment variables defined in the `.env` file.

This is probably caused by https://github.com/wxt-dev/wxt/pull/2181

```diff
- import { config } from 'dotenv';
```

Simply importing `dotenv/config` auto-imports the `.env` file. From the [README](https://github.com/motdotla/dotenv#readme):

> ```ts
> // index.js
> require('dotenv').config()
> // or import 'dotenv/config' // for esm
> 
> console.log(`Hello ${process.env.HELLO}`)
> ```
> 
> That's it. `process.env` now has the keys and values you defined in your `.env` file.

### Reproduction

- https://github.com/hyunbinseo/better-surf/releases/tag/v0.0.28
- https://github.com/hyunbinseo/better-surf/actions/runs/24392573474/job/71242144892

### Steps to reproduce

_No response_

### System Info

```shell
System:
  OS: Windows 11 10.0.26200
  CPU: (8) x64 Intel(R) Core(TM) Ultra 7 258V
  Memory: 6.69 GB / 31.48 GB
Binaries:
  Node: 24.14.1 - C:\Users\hyunb\AppData\Local\fnm_multishells\6356_1776160262050\node.EXE
  npm: 11.11.0 - C:\Users\hyunb\AppData\Local\fnm_multishells\6356_1776160262050\npm.CMD
  pnpm: 10.33.0 - C:\Users\hyunb\AppData\Local\fnm_multishells\6356_1776160262050\pnpm.CMD
  Deno: 2.4.0 - C:\Users\hyunb\.deno\bin\deno.EXE
Browsers:
  Chrome: 147.0.7727.56
  Edge: Chromium (140.0.3485.54)
  Firefox: 149.0.2 - C:\Program Files\Mozilla Firefox\firefox.exe
```

### Used Package Manager

npm

### Validations

- [x] Read the [Contributing Guidelines](https://github.com/wxt-dev/wxt/blob/main/CONTRIBUTING.md).
- [x] Read the [docs](https://wxt.dev/guide/installation.html).
- [x] Check that there isn't [already an issue](https://github.com/wxt-dev/wxt/issues) that reports the same bug to avoid creating a duplicate.
- [x] Check that this is a concrete bug. For Q&A open a [GitHub Discussion](https://github.com/wxt-dev/wxt/discussions) or join our [Discord Chat Server](https://discord.gg/ZFsZqGery9).
- [x] The provided reproduction is a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) of the bug.


Discussion:
Very weird. Not sure why this change would have effected `wxt submit`, that's an alias for `publish-browser-extension`, so it's more likely a change there that broke it.

WXT was [never importing from `dotenv/config`](https://github.com/wxt-dev/wxt/pull/2181/changes), it manually called `config()`, so that's not the problem.

Couple of things I noticed:

1. Your env is defined in `.env`, but `publish-browser-extension` [has always loaded `.env.submit`](https://github.com/aklinker1/publish-browser-extension/blob/d2ae729a9369a7b96082eee7d1bcce26c82b3b47/src/cli.ts#L9), it's never loaded `.env`
2. You're also trying to load IDs from github action secrets: https://github.com/hyunbinseo/better-surf/blob/23d3e799c328b7e982799b13caf7807a78bb1d90/.github/workflows/submit.yml#L34-L40, which could be conflicting... but it worked for your 27 release.

Looking at your lockfile, it looks like the last success was with `publish-browser-extension@4.0.4`, and it failed after upgrading to `4.0.5`... Odd, that was a very small release that didn't touch env at all... Hmm, that does make me think WXT might be the cause...

Regardless, you should be putting this files in `.env.submit`, not `.env`. I'm not sure why your CI worked at all. Alternatively, you could add `NODE_OPTIONS: ---env-file=.env` on your submit step's environment, to have node load that file in addition to `.env.submit`.

```diff
      - name: Submit to stores
        run: |
          pnpm wxt submit \
            --chrome-zip .output/*-chrome.zip \
            --firefox-zip .output/*-firefox.zip --firefox-sources-zip .output/*-sources.zip
        env:
+         NODE_OPTIONS: --env-file=.env
          CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
          CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
          CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
          CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
          FIREFOX_EXTENSION_ID: ${{ secrets.FIREFOX_EXTENSION_ID }}
          FIREFOX_JWT_ISSUER: ${{ secrets.FIREFOX_JWT_ISSUER }}
          FIREFOX_JWT_SECRET: ${{ secrets.FIREFOX_JWT_SECRET }}
```
@aklinker1 thanks for the quick review.

I was using `.env.submit` briefly when I was directly using `publish-browser-extension` directly:

- https://github.com/hyunbinseo/better-surf/commit/599bf2f162af499729b3ba059a9c303735b4d3fb

because it wasn't autoloading the `.env` file. This is why I believe `wxt` was importing `.env` in the previous version.
Isn't `.env` supposed be loaded in all cases though?

> WXT supports [dotenv files the same way as Vite](https://vite.dev/guide/env-and-mode.html#env-files). Create any of the following files:
> 
> plaintext
> 
> ```
> .env
> .env.local
> .env.[mode]
> .env.[mode].local
> .env.[browser]
> .env.[browser].local
> .env.[mode].[browser]
> .env.[mode].[browser].local
> ```
> 
> And any environment variables listed inside them will be available at runtime:

https://wxt.dev/guide/essentials/config/environment-variables.html#dotenv-files
The submit commands are special, they are just aliases to `publish-browser-extension`. env files are loaded when we resolve WXT's config internally, but that's not necessary to forward the CLI command to another CLI.

Also note "runtime" in "And any environment variables listed inside them will be available at runtime" - the CLI is build-time, not runtime. Runtime is when the extension is running in the browser.
Fixed in https://github.com/hyunbinseo/better-surf/commit/14375f74adf90901ca54cb909175912b3ff481cb

Maybe the behavior of `loadEnvFile` in `wxt.config.ts` changed?

The `NODE_OPTIONS` suggestion did not work: https://github.com/nodejs/node/issues/511474

> node: --env-file= is not allowed in NODE_OPTIONS

OK, found the problem

I modified `publish-browser-extension` to print the resolved chrome config to see what `extensionId` it resolved internally.

###### `git checkout v0.0.28`

```
$ CHROME_EXTENSION_ID="" pnpm wxt submit --chrome-zip chrome.zip
{
  zip: 'chrome.zip',
  extensionId: undefined,
  ...
}
$ pnpm wxt submit --chrome-zip chrome.zip
{
  zip: 'chrome.zip',
  extensionId:'jhpnholkflkpnadabmadfcfgnbekijjf',
  ...
}
```

###### `git checkout v0.0.27`

```
$ CHROME_EXTENSION_ID="" pnpm wxt submit --chrome-zip chrome.zip
{
  zip: 'chrome.zip',
  extensionId:'jhpnholkflkpnadabmadfcfgnbekijjf',
  ...
}
$ pnpm wxt submit --chrome-zip chrome.zip
{
  zip: 'chrome.zip',
  extensionId:'jhpnholkflkpnadabmadfcfgnbekijjf',
  ...
}
```

In v0.0.27, even if the env var was set to an empty string, it worked. But in v0.0.28, if you set the env var to an empty string, it would not be overriden.

---

I was confused at first because my local testing couldn't reproduce the problem. But then I remembered your action is passing a blank string for the `CHROME_EXTENSION_ID` env var. You secret is empty, and it's [listed here](https://github.com/hyunbinseo/better-surf/blob/23d3e799c328b7e982799b13caf7807a78bb1d90/.github/workflows/submit.yml#L34-L40), thus a blank string. You can see this by looking at the workflow logs as well:

<img width="698" height="339" alt="Image" src="https://github.com/user-attachments/assets/85da7878-4fcb-476a-99e2-0f614236a628" />

So the PR you originally mentioned was the cause - ***after that PR, env vars that are blank strings are not overridden***.

Specifically, if you modify WXT's `loadEnv` as follows:

```ts
  console.log({ parsed })
  const res = expand({
    parsed,
    processEnv: { ...process.env }
  });
  console.log({ parsed, res })
  console.log("REAL", process.env)
```

We get the following logs:

```
{
  parsed: {
    CHROME_EXTENSION_ID: 'jhpnholkflkpnadabmadfcfgnbekijjf',
    FIREFOX_EXTENSION_ID: 'better-surf',
    FIREFOX_EXTENSION_UUID: 'f7ca4a3a-350a-45d5-a0fa-bf27cd37aa47'
  }
}
{
  parsed: {
    CHROME_EXTENSION_ID: 'jhpnholkflkpnadabmadfcfgnbekijjf',
    FIREFOX_EXTENSION_ID: 'better-surf',
    FIREFOX_EXTENSION_UUID: 'f7ca4a3a-350a-45d5-a0fa-bf27cd37aa47'
  },
  res: {
    parsed: {
      CHROME_EXTENSION_ID: 'jhpnholkflkpnadabmadfcfgnbekijjf',
      FIREFOX_EXTENSION_ID: 'better-surf',
      FIREFOX_EXTENSION_UUID: 'f7ca4a3a-350a-45d5-a0fa-bf27cd37aa47'
    },
    processEnv: {
      CHROME_EXTENSION_ID: 'jhpnholkflkpnadabmadfcfgnbekijjf',
      ...
    }
  }
}
REAL {
  ...
  CHROME_EXTENSION_ID: '',
  ...
}
```

So you can see WXT no longer applies the result of `dotenv-expand` to the real `process.env`, as `dotenv` was doing before.

This only seems to cause issues if you have the same env var set multiple ways, once outside a `.env` and once inside a `.env`. I think the current behavior is more correct - you clearly set the env var to a blank string when running the command, which should take priority.

If we wanted to fix this, the solution would be to let `dotenv-expand` modify the real `process.env` like so:

```diff
  const res = expand({
    parsed,
-   processEnv: { ...process.env }
+   processEnv: process.env
  });
```

[Vite doesn't do this](https://github.com/vitejs/vite/blob/a704abc4e6f1528837030cf92cb7a2de65da54cc/packages/vite/src/node/env.ts#L72-L75), but the [package's docs](https://npmx.dev/package/dotenv-expand) imply we should 🤔 
BUT vite doesn't use this to make variables available via `process.env` - it uses the result to replace `import.meta.env.*` in your bundle.

So I think I'll make this change.



Patch:
diff --git a/.gitignore b/.gitignore
index 709b1f220..422ab5c94 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 .DS_Store
 .env
 .env.*
+!packages/wxt/src/core/utils/__tests__/fixtures/.env
 .idea
 .output
 .webextrc
diff --git a/.tool-versions b/.tool-versions
index 461bdea7b..066a07c57 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1,1 +1,2 @@
 bun 1.3.12
+nodejs 24.14.1
diff --git a/bun.lock b/bun.lock
index 59926ba13..32b305cb4 100644
--- a/bun.lock
+++ b/bun.lock
@@ -91,7 +91,7 @@
         "@types/har-format": "*",
       },
       "devDependencies": {
-        "@types/chrome": "^0.1.40",
+        "@types/chrome": "0.1.40",
         "@types/node": "^20.0.0",
         "nano-spawn": "^2.0.0",
         "typescript": "^5.9.3",
diff --git a/packages/wxt/src/core/utils/env.ts b/packages/wxt/src/core/utils/env.ts
index 38871ca57..17bb8eb20 100644
--- a/packages/wxt/src/core/utils/env.ts
+++ b/packages/wxt/src/core/utils/env.ts
@@ -1,6 +1,6 @@
-import { readFileSync, existsSync } from 'node:fs';
-import { parseEnv } from 'node:util';
 import { expand } from 'dotenv-expand';
+import { existsSync, readFileSync } from 'node:fs';
+import { parseEnv } from 'node:util';
 import type { TargetBrowser } from '../../types';
 
 /** Load environment files based on the current mode and browser. */
@@ -32,13 +32,8 @@ export function loadEnv(mode: string, browser: TargetBrowser) {
     }),
   );
 
-  // Make a copy of `process.env` so that `dotenv-expand` doesn't re-assign the
-  // expanded values to the global `process.env`.
-  const processEnv = { ...process.env } as Record<string, string>;
-
   expand({
     parsed,
-    processEnv,
   });
 
   return parsed;


Test patch:
diff --git a/packages/wxt/src/core/utils/__tests__/env.test.ts b/packages/wxt/src/core/utils/__tests__/env.test.ts
new file mode 100644
index 000000000..21cac3811
--- /dev/null
+++ b/packages/wxt/src/core/utils/__tests__/env.test.ts
@@ -0,0 +1,42 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { loadEnv } from '../env';
+
+const cwd = process.cwd();
+
+describe('Env Utils', () => {
+  beforeEach(() => {
+    if (process.cwd() !== cwd) process.chdir(cwd);
+    delete process.env.TEST_VAR;
+    delete process.env.EXPANDED;
+  });
+
+  describe('loadEnv', () => {
+    beforeEach(() => {
+      process.chdir(`${import.meta.dirname}/fixtures`);
+    });
+
+    it('should load env vars into the real `process.env`', () => {
+      loadEnv('testing', 'chrome');
+      expect(process.env.TEST_VAR).toEqual('expected');
+    });
+
+    it('should override blank strings in process.env', () => {
+      process.env.TEST_VAR = '';
+      loadEnv('testing', 'chrome');
+      expect(process.env.TEST_VAR).toEqual('expected');
+    });
+
+    it('should not override non-blank strings in process.env', () => {
+      process.env.TEST_VAR = 'non-blank';
+      loadEnv('testing', 'chrome');
+      expect(process.env.TEST_VAR).toEqual('non-blank');
+    });
+
+    // Node doesn't return vars in the same order as they're defined:
+    // https://github.com/nodejs/node/issues/62736
+    it.skip('should expand env vars into the real `process.env`', () => {
+      loadEnv('testing', 'chrome');
+      expect(process.env.EXPANDED).toEqual('expected expanded');
+    });
+  });
+});
diff --git a/packages/wxt/src/core/utils/__tests__/fixtures/.env b/packages/wxt/src/core/utils/__tests__/fixtures/.env
new file mode 100644
index 000000000..a6fd32b1f
--- /dev/null
+++ b/packages/wxt/src/core/utils/__tests__/fixtures/.env
@@ -0,0 +1,2 @@
+TEST_VAR="expected"
+EXPANDED="$TEST_VAR expanded"