Parcel 17 ๐ฆ Environment Variables โ .env files & process.env
Parcel’s environment variable handling is a build-time substitution system, not a runtime lookup. When you write process.env.API_KEY in your source, Parcel replaces that expression with the literal string value from your .env file or shell environment during the build. The result is a bundle that contains the value, not a reference to a global object. This is why the documentation insists on static property access: a dynamic lookup like process.env[key] cannot be resolved at build time, so Parcel cannot substitute it. The mechanism is simple, but the file precedence rules and the library-target behavior are where the surprises live.
The .env loading follows a cascade that depends on NODE_ENV. The base .env file is always loaded. Then, depending on the mode, Parcel loads .env.local, .env.[NODE_ENV], and .env.[NODE_ENV].local in a specific order, with more specific files overriding less specific ones. The NODE_ENV value itself is set automatically by Parcel: production during parcel build, and development otherwise. This automatic setting is what determines which environment-specific files are loaded, and it can be overridden by setting NODE_ENV in your shell before running the command.
Key point: Parcel inlines environment variables at build time. Only static property access (process.env.NAME) is supported. The value is baked into the bundle, and .env.local is deliberately skipped when NODE_ENV=test so that tests are reproducible across machines.
Why the build-time substitution model exists
The browser has no process.env. In Node.js, process.env is a live object that reflects the environment the process was started with. In a browser, there is no process global, and there is no concept of a shell environment. A bundler that targets the browser must either polyfill the process object (which adds weight and complexity) or substitute the environment variables at build time. Parcel chooses substitution. The process.env.API_KEY expression is replaced with "sk-abc123", and the resulting code runs in any browser without a polyfill.
The security boundary. The substitution is also a filtering mechanism. Only the variables that are explicitly referenced with static access are inlined. A variable in .env that is never mentioned in the source code does not appear in the output. This is not a hard security boundaryโa variable that is referenced in the source is inlined regardless of whether it should be exposed to the clientโbut it prevents accidental leakage of variables that the build does not need. The inlineEnvironment option can be set to an array of glob patterns to further restrict which variables are allowed to be inlined, which is the recommended hardening step for applications that handle secrets.
The library-target problem. When Parcel builds a library (a package intended to be consumed by another bundler), inlining process.env.NODE_ENV at build time is often the wrong behavior. A React component library that checks process.env.NODE_ENV === 'development' expects the consumer’s bundler to perform that substitution, so that the dev warning is present in development builds and stripped in production. Parcel’s default inlining replaces the check with the library’s build-time NODE_ENV, which is production during parcel build, permanently stripping the dev warning from the library. The @parcel/transformer-js configuration with inlineEnvironment: false is intended to address this, but the behavior has been inconsistent: reports indicate that NODE_ENV may still be inlined regardless of the setting, and undefined variables may be replaced with undefined rather than left intact . The workaround for library authors is to treat the package as a Node target (engines: { node: ">=12" }), which changes Parcel’s substitution behavior to preserve the expressions .
The trade-off. Build-time substitution is fast and produces small bundles, but it means the environment is frozen at build time. A single build artifact cannot be deployed to staging and production with different API keys. Each environment requires its own build. This is the standard model for static sites and client-side applications, but it is incompatible with the twelve-factor approach of building once and configuring at runtime. A Node target preserves process.env expressions for runtime resolution, but a browser target cannot.
a. The .env file cascade and NODE_ENV
Parcel loads .env files from the project root, alongside package.json. The files are parsed with dotenv syntax: NAME=value pairs, one per line, with # for comments . The cascade depends on the NODE_ENV value.
# .env โ base file, always loaded
API_URL=https://api.example.com
FEATURE_FLAG=on
# .env.development โ loaded when NODE_ENV=development
API_URL=http://localhost:3000
# .env.production โ loaded when NODE_ENV=production
API_URL=https://api.example.com
The precedence order is: .env is loaded first, then .env.local (unless NODE_ENV=test), then .env.[NODE_ENV], then .env.[NODE_ENV].local. Variables in later files override variables in earlier files. The .env.local file is skipped when NODE_ENV=test because tests should produce the same result regardless of the developer’s local overrides .
The NODE_ENV value itself is set by Parcel: production for parcel build, development for parcel (the dev server). It can be overridden in the shell:
# Override NODE_ENV for a staging build
NODE_ENV=staging parcel build src/index.html
This override changes which .env.staging file is loaded, if it exists. It does not change the build modeโparcel build still performs a production buildโbut it changes the environment variable cascade .
b. The static access requirement and the library workaround
Only static property access is supported. Parcel’s parser must be able to see the exact property name at build time.
// โ
Supported: static property access
const apiUrl = process.env.API_URL;
// โ
Supported: destructuring
const { API_URL, API_KEY } = process.env;
// โ Not supported: dynamic property access
const key = 'API_URL';
const value = process.env[key]; // Parcel cannot resolve this
The dynamic access limitation is a consequence of the substitution model. Parcel cannot know which variable to inline if the property name is determined at runtime. The expression is left as-is, which means it references a process.env object that does not exist in the browser, producing a runtime error .
For library authors who need to preserve process.env.NODE_ENV checks for the consumer’s bundler, the inlineEnvironment configuration is the documented mechanism, but the behavior has been unreliable. The workaround that reliably preserves the expressions is to declare the package as a Node target:
// package.json โ treat the package as a Node target
{
"engines": {
"node": ">=12"
}
}
This changes Parcel’s substitution behavior. The process.env expressions are left intact in the output, and the consumer’s bundler performs the substitution. The trade-off is that the package is no longer treated as a browser target by Parcel, which may affect other optimizations, but for a library that expects to be consumed by another bundler, this is the correct behavior .
Complete Example Session
// ============================================
// PART 1: THE .ENV FILES
// ============================================
// .env
// API_URL=https://api.example.com
// ANALYTICS_KEY=abc123
// DEBUG=false
// .env.development
// API_URL=http://localhost:3000
// DEBUG=true
// .env.production
// API_URL=https://api.example.com
// DEBUG=false
// ============================================
// PART 2: STATIC ACCESS IN SOURCE
// ============================================
// src/config.js
// Only static property access is supported.
// Parcel replaces these expressions at build time.
export const apiUrl = process.env.API_URL;
export const analyticsKey = process.env.ANALYTICS_KEY;
export const isDebug = process.env.DEBUG === 'true';
// โ Dynamic access is not substituted.
// const key = 'API_URL';
// const url = process.env[key]; // ReferenceError at runtime
// ============================================
// PART 3: NODE_ENV AND THE CASCADE
// ============================================
// During `parcel` (dev server):
// NODE_ENV=development
// .env is loaded, then .env.development overrides
// API_URL is http://localhost:3000
// DEBUG is true
// During `parcel build`:
// NODE_ENV=production
// .env is loaded, then .env.production overrides
// API_URL is https://api.example.com
// DEBUG is false
// ============================================
// PART 4: OVERRIDING NODE_ENV IN THE SHELL
// ============================================
// NODE_ENV=staging parcel build src/index.html
//
// Parcel loads .env, then .env.staging (if it exists).
// Variables not in .env.staging fall back to .env.
// NODE_ENV itself is 'staging' in the bundle.
// ============================================
// PART 5: THE .ENV.LOCAL EXCEPTION
// ============================================
// .env.local is loaded for local overrides.
// It is NOT loaded when NODE_ENV=test.
//
// NODE_ENV=test parcel build src/index.html
// โ .env.local is skipped
// โ tests produce the same result on every machine
// ============================================
// PART 6: THE LIBRARY TARGET WORKAROUND
// ============================================
// package.json โ for a library that should preserve process.env
{
"engines": {
"node": ">=12"
}
}
// With this, Parcel leaves process.env expressions intact.
// The consumer's bundler performs the substitution.
// This is the workaround for the inlineEnvironment inconsistency.
// ============================================
// PART 7: THE inlineEnvironment OPTION
// ============================================
// package.json โ restrict which variables are inlined
{
"@parcel/transformer-js": {
"inlineEnvironment": ["API_*", "ANALYTICS_*"]
}
}
// Only variables matching the glob patterns are inlined.
// This is a hardening step for applications with secrets.
// The default is to inline all referenced variables.
// ============================================
// PART 8: THE TYPESCRIPT SETUP
// ============================================
// TypeScript does not know about process.env by default.
// Install @types/node for the process global.
// npm install --save-dev @types/node
// Do not import process from 'process'.
// Use the global process object directly.
// const key = process.env.API_KEY; // โ
// import process from 'process'; // โ
// ============================================
// PART 9: CACHE INVALIDATION
// ============================================
// Sometimes a new .env file does not take effect.
// Parcel caches the build, and the cache may not
// invalidate on .env changes in some versions.
// Delete the .cache directory and rebuild.
// rm -rf .cache
// parcel build src/index.html
// ============================================
// PART 10: VERIFICATION
// ============================================
// 1. Add a variable to .env: TEST_VAR=hello
// 2. Reference it: console.log(process.env.TEST_VAR);
// 3. Build: parcel build src/index.html
// 4. Search the output for "hello".
// 5. The value is inlined. The process.env expression is gone.
// 6. Verify the variable is not in the output if it is not referenced.
The ten parts covered the .env cascade, the static access requirement, the NODE_ENV automatic setting, the shell override, the .env.local exception, the library workaround, the inlineEnvironment option, the TypeScript setup, and the cache invalidation. The through-line is that the substitution is a build-time transformation, and every limitation follows from that fact.
Quick Reference
.env file cascade
| File | Loaded when | Priority |
|---|---|---|
.env | Always | Lowest |
.env.local | NODE_ENV !== 'test' | Overrides .env |
.env.[NODE_ENV] | Always (if exists) | Overrides local |
.env.[NODE_ENV].local | NODE_ENV !== 'test' | Highest |
NODE_ENV defaults
| Command | Default NODE_ENV |
|---|---|
parcel (dev server) | development |
parcel build | production |
Access patterns
| Pattern | Supported | Behavior |
|---|---|---|
process.env.NAME | Yes | Inlined as string |
const { NAME } = process.env | Yes | Inlined as string |
process.env[key] | No | Left as-is; runtime error |
Best Practices
โ Do This:
// Use static property access
const key = process.env.API_KEY; // โ
// Use destructuring for multiple variables
const { API_KEY, API_URL } = process.env; // โ
// Install @types/node for TypeScript
// npm install --save-dev @types/node // โ
// Delete .cache if a new .env file is not picked up
// rm -rf .cache // โ
โ Don’t Do This:
// Use dynamic property access
const key = 'API_KEY';
const value = process.env[key]; // โ
// Import process from 'process'
import process from 'process'; // โ
// Expect runtime resolution in a browser target
// The value is frozen at build time. // โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
Variable is undefined in the bundle | Dynamic property access | Use process.env.NAME |
Variable is not updated after editing .env | Cache not invalidated | Delete .cache and rebuild |
Library’s NODE_ENV check is stripped | Parcel inlines at build time | Set engines: { node: ">=12" } |
.env.local is not loaded | NODE_ENV=test | This is intentional; use .env.test |
TypeScript error on process | @types/node not installed | Install the types package |
Real-World Examples
1. Static access
const apiUrl = process.env.API_URL;
2. Destructuring
const { API_KEY, API_URL } = process.env;
3. NODE_ENV check
if (process.env.NODE_ENV === 'development') { console.log('dev'); }
4. .env file
API_URL=https://api.example.com
5. Shell override
NODE_ENV=staging parcel build src/index.html
6. Library target workaround
"engines": { "node": ">=12" }
7. inlineEnvironment restriction
"inlineEnvironment": ["API_*"]
8. TypeScript setup
npm install --save-dev @types/node
9. Cache invalidation
rm -rf .cache && parcel build src/index.html
10. .env.local exception
# Not loaded when NODE_ENV=test
Visual
The .env Cascade
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ WHICH FILE WINS WHEN? โ
โ โ
โ NODE_ENV=development NODE_ENV=production โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ .env โ โ .env โ โ
โ โ (base, lowest) โ โ (base, lowest) โ โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ โ
โ โผ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ .env.local โ โ .env.local โ โ
โ โ (overrides base) โ โ (overrides base) โ โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ โ
โ โผ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ .env.development โ โ .env.production โ โ
โ โ (overrides local) โ โ (overrides local) โ โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ โ
โ โผ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ .env.development โ โ .env.production โ โ
โ โ .local (highest) โ โ .local (highest) โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ .env.local is skipped when NODE_ENV=test. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Build-Time Substitution
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ WHAT HAPPENS TO process.env.API_KEY โ
โ โ
โ Source code Bundle output โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ const key = โ โ const key = โ โ
โ โ process.env.API_KEYโ โโโโโถ โ "sk-abc123"; โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ The expression is replaced with the literal value. โ
โ The process global is not needed in the browser. โ
โ The value is frozen at build time. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Static vs Dynamic Access
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ WHY DYNAMIC ACCESS FAILS โ
โ โ
โ process.env.API_KEY โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Parcel sees the โ โ
โ โ exact property name โ โ
โ โ โ inlines the value โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ process.env[key] โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Parcel does not โ โ
โ โ know the property โ โ
โ โ name at build time โ โ
โ โ โ leaves it as-is โ โ
โ โ โ runtime error โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ The parser must see the name. Destructuring works because โ
โ the names are static. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| Loading mechanism | dotenv, from project root |
NODE_ENV default | production (build), development (dev) |
| Access pattern | Static only (process.env.NAME) |
.env.local exception | Skipped when NODE_ENV=test |
| Library workaround | engines: { node: ">=12" } |
| Cache invalidation | Delete .cache if changes not picked up |
| TypeScript types | @types/node |
Key takeaways:
- Environment variables are inlined at build time. The
process.env.NAMEexpression is replaced with the literal value from the.envfile or shell. The resulting bundle contains the value, not a runtime reference. This is why the browser does not need aprocesspolyfill . - Only static property access is supported. Parcel must know the variable name at build time.
process.env.NAMEandconst { NAME } = process.envwork.process.env[key]does not and produces a runtime error . - The
.envcascade depends onNODE_ENV..envis always loaded, then.env.local,.env.[NODE_ENV], and.env.[NODE_ENV].localoverride it in that order..env.localis skipped whenNODE_ENV=testfor reproducible tests . - Library targets need the Node engines workaround. Inlining
process.env.NODE_ENVin a library strips the dev/prod checks that the consumer’s bundler should perform. Settingengines: { node: ">=12" }preserves the expressions . - The
inlineEnvironmentoption restricts which variables are inlined. An array of glob patterns limits substitution to matching variables. This is a hardening step for applications that handle secrets .
Remember: Parcel’s environment variable handling is a build-time substitution, not a runtime lookup. The value is baked into the output, and the process.env expression is gone. This is why the browser does not need a process polyfill, and why the variable name must be static. The .env cascade determines which value is baked in, and NODE_ENV determines which files are loaded. For applications, the default behavior is correct. For libraries, the Node engines workaround preserves the expressions for the consumer’s bundler. The cache is the most common source of “it’s not working”โdelete it and rebuild.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!