Injecting Environment Variables at Build Time with esbuild define
esbuild’s define option replaces identifiers with compile-time constants before parsing, which is how you inline process.env.NODE_ENV, feature flags, and API URLs so the minifier can then eliminate dead branches. This guide gets the JSON encoding right, avoids leaking secrets into the bundle, and verifies the substitution. It applies the build-time-injection idea from esbuild plugin patterns using a built-in option rather than a plugin; for the API surface see esbuild & Turbopack Workflows.
The reason define matters is that the browser has no process object, so any process.env.X access that survives into a browser bundle throws process is not defined the moment that line runs. Bundlers built for the web solve this the same way: they treat a small set of identifiers as compile-time constants and textually substitute them before the code is ever parsed as an expression. esbuild exposes that substitution directly through define, and because it happens before parsing rather than at runtime, the substituted value is a genuine literal in the abstract syntax tree — not a variable read that the optimizer has to reason about conservatively. That distinction is the whole point: a literal "production" sitting in a comparison lets the constant-folding and dead-code-elimination passes prove a branch is unreachable and delete it, whereas a variable read forces them to keep the branch in case the value changes. Getting define right is therefore two wins at once — you stop the runtime crash and you unlock the size reduction that makes feature flags free in production.
define is not unique to esbuild; Vite’s define, webpack’s DefinePlugin, and Rollup’s @rollup/plugin-replace all implement the same textual-substitution contract, so the encoding rules and the secret-leakage hazards below transfer directly. What differs is only the option name and where you configure it. Everything in this guide — JSON-encoding every value, keeping secrets out, pairing substitution with minification — applies verbatim across all four tools.
Where this sits in the build pipeline matters for reasoning about it. define is not a plugin and does not participate in the resolve/load/transform hooks; it is a first-class replacement pass that esbuild applies during its own lexing stage, ahead of any user plugin’s onLoad result being parsed and ahead of tree-shaking and minification. That ordering has a practical consequence: a plugin cannot see or rewrite the substituted value, because the value is already a literal by the time the parser hands the AST to later passes. If you need substitution that depends on the module being loaded — per-file constants, or values computed from the source — define is the wrong tool and you want an onLoad transform instead. define is for global, module-independent constants that are identical everywhere they appear.
One more framing that prevents a class of bugs: define keys are matched against identifier expressions, not arbitrary text. esbuild matches process.env.NODE_ENV as a member-access chain, so it will replace process.env.NODE_ENV wherever that exact expression appears, but it will not touch process["env"]["NODE_ENV"], a destructured const { NODE_ENV } = process.env, or a string that merely contains those characters. This is why aliasing environment access behind a helper defeats define entirely — the optimizer never sees the literal it needs. Keep the access shape identical to the key you define, and the substitution is reliable.
define runs before parsing, so the value is a literal the optimizer can reason about and prune around.Prerequisites & reproducible setup
# esbuild 0.25.x, Node 18+
mkdir define-demo && cd define-demo && npm init -y
npm install --save-dev esbuild@0.25.0
cat > app.ts <<'TS'
if (process.env.NODE_ENV !== 'production') {
console.debug('dev-only logging');
}
export const api = process.env.API_URL;
TS
Without define, esbuild leaves process.env.NODE_ENV as a runtime property access, which throws process is not defined in the browser and keeps the debug branch in the output. The app.ts above is deliberately minimal but exercises both behaviours we care about: a conditional that should collapse to nothing in production, and a value that should survive as an inlined literal. Building it twice — once with define and once without — makes the difference measurable rather than theoretical, and the two grep checks later in this guide turn that difference into a pass/fail signal you can wire into CI.
Pin the esbuild version explicitly. The constant-folding and dead-code-elimination heuristics are implementation details that have shifted between minor releases, so a build that prunes a branch under 0.25.x is not guaranteed to prune the identical branch under a much older or newer line without re-verifying. Pinning also keeps the grep expectations below stable: if the counts suddenly change after a dependency bump, the version delta is the first thing to check.
define removes the process access rather than polyfilling it.Diagnosis workflow
- List which identifiers you want constant. Usually
process.env.NODE_ENV, a fewprocess.env.FEATURE_*flags, and public URLs — never secrets. The discipline here is to enumerate the exact set rather than reach for a blanket rule. Every entry you add is an identifier that becomes frozen at build time, so the list doubles as an audit of what your bundle assumes about its environment. If an identifier is not on the list, it stays a runtime access, and in a browser bundle that means it must resolve to something the browser actually provides. Keeping the list short and explicit is what lets a reviewer glance at the config and know precisely which values are baked in. - JSON-encode every value.
definereplaces the identifier with the raw text you give it, so a string must be wrapped in quotes viaJSON.stringify, or esbuild parses it as an identifier and errors. The mental model is that the right-hand side of adefineentry is spliced into the source as source code, not as data.JSON.stringify("production")yields the seven characters"production"including the quotes, which is a valid string literal; the bare wordproductionis a reference to a variable that does not exist.JSON.stringifyalso handles the awkward values correctly — embedded quotes, newlines, and non-ASCII characters are all escaped into a literal that parses back to the original value — which is exactly why you reach for it instead of hand-writing quotes. - Separate public from secret. Anything in
defineends up in the shipped bundle. A token or private key inlined this way is readable by anyone who opens DevTools. The failure mode is quiet: the build succeeds, the app works, and the secret sits in plain text inside a JavaScript file served to every visitor and cached by every CDN in front of you. There is no runtime error to alert you, which is why the public/secret split has to be a deliberate rule enforced in the config, not something you hope to catch in review. When a value genuinely must reach the client but must not be public, the answer is notdefine— it is a request to a server route that holds the secret and returns only what the client is allowed to see.
define is not a secret store.How it works under the hood
The substitution runs during esbuild’s lexer/parser stage. As the parser walks the token stream it recognises the identifier and member-access patterns you registered as define keys and, instead of emitting a normal identifier or property-access node, it emits the parsed AST of the replacement text. Two details follow from this. First, the replacement text is itself parsed as JavaScript, which is why JSON.stringify is mandatory — esbuild is not concatenating strings, it is parsing your value into nodes, and an unquoted value parses into the wrong kind of node. Second, because the value is a real literal node from that point on, every downstream pass treats it as a constant with no provenance to track. There is no variable binding, no assignment, and therefore no possibility that some other code path reassigns it.
Constant folding is the next pass to benefit. Once process.env.NODE_ENV is the literal "production", an expression like process.env.NODE_ENV !== 'production' is a comparison between two known string literals, which esbuild folds to the boolean false. Dead-code elimination then observes an if (false) { ... } and removes the entire consequent, including any imports that were only referenced inside it — those imports become unreferenced and are tree-shaken away in turn. This cascade is why a single well-placed flag can remove not just a branch but a whole dependency subtree from the production bundle. The chain is strictly ordered: substitute, fold, eliminate, tree-shake, and each step depends on the previous one having produced a provable constant.
The important caveat is that folding only reaches expressions esbuild can evaluate statically. process.env.NODE_ENV !== 'production' folds; someFunction(process.env.NODE_ENV) does not, because esbuild will not evaluate an arbitrary function call at build time. The literal is still inlined — you still avoid the process is not defined crash — but the branch is not pruned, because there is no static boolean for dead-code elimination to act on. If you want the size win, keep the flag comparison simple and direct so the folder can see through it.
Annotated solution config
// build.mjs — esbuild 0.25.x, Node 18+
import * as esbuild from 'esbuild';
// Read only the PUBLIC vars you intend to ship. JSON.stringify each value.
const publicEnv = {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'process.env.API_URL': JSON.stringify(process.env.API_URL ?? 'https://api.example.com'),
// A bare boolean flag: note the value is raw text, so no quotes here.
'__DEV__': JSON.stringify(process.env.NODE_ENV !== 'production'),
};
await esbuild.build({
entryPoints: ['app.ts'],
bundle: true,
minify: true, // required for the dead branch to actually be removed
format: 'esm',
outfile: 'dist/out.js',
define: publicEnv,
});
A few choices in that config are load-bearing. The ?? 'production' fallback on NODE_ENV means an unset environment variable defaults to the safe, optimized build rather than accidentally producing a development bundle in CI — a missing variable should fail toward the smaller, faster output, not the debug one. The __DEV__ entry shows the other common shape: a bare global identifier rather than a process.env.* access. Defining a short global like __DEV__ and gating debug code behind if (__DEV__) is often cleaner than repeating process.env.NODE_ENV !== 'production' everywhere, and it reads identically in TypeScript once you declare declare const __DEV__: boolean in an ambient types file so the compiler stops complaining.
Note also that define values are computed once, when build.mjs runs, and then frozen into the output. They are a snapshot of the build machine’s environment at build time, not a live view of anything. If API_URL differs between staging and production, you produce two different bundles from two different builds — you do not ship one bundle that reads the URL at runtime. That is the fundamental trade-off of build-time injection: you exchange runtime flexibility for a smaller, faster, statically-analyzable bundle. When you need the same artifact to behave differently across environments without rebuilding, define is the wrong mechanism and a runtime config fetch is the right one.
For projects that keep public configuration in a .env file, wire the file into the build explicitly rather than letting the shell leak arbitrary variables into the bundle:
// build.mjs — esbuild 0.25.x, Node 18+ (public .env values only)
import * as esbuild from 'esbuild';
import { readFileSync } from 'node:fs';
// Parse a minimal KEY=value .env into an object. Only whitelisted keys ship.
const raw = Object.fromEntries(
readFileSync('.env.public', 'utf8')
.split('\n')
.filter((l) => l && !l.startsWith('#'))
.map((l) => {
const i = l.indexOf('=');
return [l.slice(0, i).trim(), l.slice(i + 1).trim()];
}),
);
const WHITELIST = ['API_URL', 'CDN_URL', 'SENTRY_DSN'];
const define = Object.fromEntries(
WHITELIST.map((k) => [`process.env.${k}`, JSON.stringify(raw[k] ?? '')]),
);
await esbuild.build({
entryPoints: ['app.ts'],
bundle: true,
minify: true,
format: 'esm',
outfile: 'dist/out.js',
define,
});
The whitelist is the safety mechanism. Reading from a dedicated .env.public file, and then only inlining an explicit list of keys, means a secret accidentally added to that file — or a secret sitting in the ambient shell environment — cannot reach the bundle unless someone deliberately adds its name to WHITELIST. That extra line of friction is exactly the point.
Verification
# esbuild 0.25.x — build for production and inspect the output.
NODE_ENV=production node build.mjs
grep -c "dev-only logging" dist/out.js # expect 0 — branch eliminated
grep -c "api.example.com" dist/out.js # expect 1 — URL inlined
The debug string is gone and the API URL is present as a literal. If the debug branch survives, either minify was off or NODE_ENV was not production at build time.
Grepping the output is a crude check but a reliable one, and its crudeness is a feature: it makes no assumptions about how esbuild formats the bundle, so it keeps working across version bumps that reshuffle the emitted code. The dev-only logging string is a marker for the branch that should not exist in production; a count of zero proves elimination happened. The api.example.com string is a marker for the value that should exist; a count of one proves the literal was inlined. Choosing marker strings that are unlikely to appear anywhere else in your dependencies is what keeps these counts unambiguous — a generic token like true or dev would collide with unrelated code and make the count meaningless.
When a check fails, the two failures have distinct signatures. A surviving debug string with minify: true set almost always means NODE_ENV was not production when build.mjs ran, so the comparison folded to true and the branch was kept — confirm by echoing the variable in the same shell that runs the build. A surviving debug string with minify: false is expected and not a bug; substitution happened but nothing pruned it. A missing URL, by contrast, points at the define key not matching the access shape in the source — check that the code reads process.env.API_URL verbatim and not through a destructure or a helper. To make this durable, promote the two greps into an assertion in CI so a regression in the build config fails the pipeline instead of silently shipping a fatter or broken bundle:
# esbuild 0.25.x — fail the build if substitution or elimination regressed.
NODE_ENV=production node build.mjs
test "$(grep -c 'dev-only logging' dist/out.js)" -eq 0 || { echo 'dead branch survived'; exit 1; }
test "$(grep -c 'api.example.com' dist/out.js)" -eq 1 || { echo 'URL not inlined'; exit 1; }
echo 'define verified'
define did its job.Gotchas & edge cases
- Forgetting
JSON.stringify.define: { 'process.env.API_URL': 'https://x' }injects the identifierhttps://x, a syntax error. The symptom is a build-time parse error pointing at generated code that does not exist in your source, which is confusing until you realise the error is in the substituted text. The root cause is treating thedefinevalue as data when esbuild treats it as source. The fix is to wrap every string value inJSON.stringify; confirm by rebuilding and watching the parse error disappear. This one fails loudly, which is the good case — you cannot ship it by accident. - Secrets in the bundle. Anything in
defineships to the client. The symptom is nothing at all — the build passes and the app runs — which is precisely why this is the dangerous entry on the list. The root cause is inlining a value that should never leave the server, usually because it was pulled from the same environment object as the public values. The fix is to keep tokens server-side and inject only public values, ideally through the whitelist pattern shown above. Confirm by grepping the shipped bundle for a known fragment of each secret and asserting zero hits in CI, so a leak fails the build rather than reaching production. minifyoff.definesubstitutes but does not prune; withoutminify(or a downstream minifier) dead branches remain and the bundle does not shrink. The symptom is a correct-but-fat bundle: theprocess is not definedcrash is gone, yetgrepstill finds the dev-only string. The root cause is that substitution and elimination are separate passes and you enabled only the first. The fix is to turn onminifyfor production builds, or feed esbuild’s output to a downstream minifier that does its own dead-code elimination. Confirm with the elimination grep returning zero.- Whole-object define. Defining
process.envto a whole object works but disables per-key dead-code elimination; prefer defining specificprocess.env.Xkeys. The symptom is that flag branches never prune even though the values are correct. The root cause is thatprocess.env.NODE_ENVnow resolves to a property access on an inlined object literal rather than to a bare string literal, and esbuild’s folder will not reach through the object read to prove the branch dead. The fix is to define each key you actually use as its ownprocess.env.Xentry. Confirm that the branch prunes with the same grep, and as a bonus you ship only the keys you named instead of the entire environment object. - Live-reload and watch mode staleness. In
--watchor a long-running dev server,definevalues are captured when the build context is created, so changing a.envvalue does not take effect until the process restarts. The symptom is edits to environment values that stubbornly do nothing. The fix is to restart the build process when environment inputs change; there is no hot path that re-reads them, because from esbuild’s perspective they are compile-time constants, not inputs to watch.
When not to use define
define is the wrong tool whenever the value must be able to change without a rebuild. If a single deployed artifact has to point at different backends per tenant, or read a URL that operations can rotate without shipping new code, bake in nothing and fetch a small JSON config at startup instead. define is also wrong for anything secret, as established, and wrong for per-module or computed constants, which belong in an onLoad transform. The rule of thumb: reach for define only when the value is public, global, and identical for the entire lifetime of the built artifact. Everything else wants a different mechanism.
Comparison with runtime config and import.meta
The alternative to build-time injection is runtime configuration: the client fetches /config.json (or reads a <meta> tag, or a window.__CONFIG__ blob the server rendered) and reads values from it at runtime. That buys you one artifact deployed everywhere, configured per environment by whatever serves the config — at the cost of a network round-trip before the value is available, and no dead-code elimination, because a runtime read is never a constant the optimizer can fold. The two approaches are complementary: use define for values that are truly fixed at build time (the build mode, feature flags baked per release) and runtime config for values that legitimately vary per deployment of the same bundle. A common, defensible split is define for NODE_ENV and release-pinned flags, runtime config for backend URLs.
import.meta.env, familiar from Vite, is worth a note because it is the same idea wearing different clothes. Vite statically replaces import.meta.env.MODE and any VITE_-prefixed variable at build time — that is define under another name, with an opinionated prefix convention that enforces the public/secret split for you by only exposing prefixed variables to client code. esbuild has no such convention, so you enforce the split yourself with the whitelist. If you are migrating from Vite to a raw esbuild build, every import.meta.env.VITE_X becomes a define entry for import.meta.env.VITE_X, JSON-encoded exactly as above.
Performance considerations
The substitution itself is effectively free — it is a comparison against a small map during a lex pass esbuild already performs, so adding define entries does not measurably slow the build. The performance story is entirely downstream: every branch you prune is code the browser never parses, never compiles, and never keeps in memory, and every import that becomes unreferenced as a result is a module that leaves the graph entirely. On a large app, gating a whole debugging or admin subsystem behind a single __DEV__ flag can remove a meaningful fraction of the production bundle at zero runtime cost. The discipline that unlocks this is writing flag checks the folder can see through — direct comparisons against defined constants — rather than routing them through helpers that hide the constant from static analysis.
Related
- esbuild plugin patterns — where build-time injection fits among the plugin hooks.
- Reducing esbuild bundle size with minify and tree-shaking — how the dead-branch elimination this enables is measured.
- esbuild transform API for TypeScript stripping — the other place esbuild options change output shape.