esbuild-plugin-env
esbuild is fast because it doesn't do much for you. That's the whole appeal and also the whole problem. env` and prefix filtering out of the box.
esbuild is fast because it doesn't do much for you. That's the whole appeal and also the whole problem. Vite gives you import.meta.env and prefix filtering out of the box. Next.js does its own thing. esbuild gives you a bundler and tells you to bring your own opinions. Environment variables are one of the things it just doesn't handle, so everyone who uses esbuild directly ends up hand-writing the same define map: reading process.env, JSON-stringifying each value, wiring it into the build config, slightly differently, every time.
esbuild-plugin-env is that hand-written define map, packaged once so I stop writing it by hand.
What it does
You give it either a schema (typed, validated, coerced: same idea as env-guard, actually, and not a coincidence) or a prefix to filter by, the way Vite does with VITE_. It reads your .env files and process.env, builds the values, and injects them via esbuild's define option so process.env.API_URL gets replaced with the literal string value at build time, not read at runtime. It can also emit a .d.ts file so process.env.WHATEVER is typed in your editor instead of being string | undefined forever.
The plugin itself is a hundred and forty-seven lines, hooked into build.onStart. Two real code paths: schema mode, where you get validation and coercion the way env-guard does it, and prefix mode, which is a straight passthrough: you don't validate, you just say "give me everything starting with PUBLIC_" and it hands them over raw. Most of the time I use prefix mode. Validation at build time is nice in theory; in practice I usually want the fast path.
The .d.ts generation is the part I actually use the most. It writes a declare namespace NodeJS { interface ProcessEnv {...} } block straight to disk based on your schema or detected keys, so autocomplete on process.env in an esbuild project stops being a guessing game. Small feature. Disproportionately useful, because typing process.env.DATABSE_URL wrong and not noticing for twenty minutes is a special kind of annoying.
The part I'll admit to
I wrote the .env parser in this plugin from scratch, the same way I did in env-guard: strip quotes, skip comments, split on the first =. Twenty lines, give or take. I wrote it twice. Not because I decided to duplicate it for some architectural reason. Because I built env-guard, then a few weeks later built this, and it didn't occur to me until much later that I'd already solved the exact same tiny problem once before. That's not a design decision. That's just what happens when you're moving fast and building things in isolation instead of pausing to check your own back catalog first.
I'm not going to pretend that's ideal. The right move, eventually, is pulling that parser into its own micro-package and having both plugins depend on it. I haven't done that yet, mostly because both parsers work fine independently and the DRY violation costs me nothing except a faint sense of having repeated myself. Some technical debt is worth paying down immediately. Some of it is genuinely fine to leave alone until it actually causes a problem. This is the second kind, for now.
Why esbuild specifically
Most of my tooling opinions point at speed. esbuild is the fastest bundler I've used by a wide margin, and when you're one person building four projects at once, the seconds you save on every rebuild compound into minutes you get back over a day. But speed comes at the cost of ergonomics: esbuild assumes you'll wire up the things a bigger framework would give you for free. Env handling is one of those things. Rather than accept "no env handling" as the tradeoff for "fast bundler," I built the missing piece myself and now I get both.
This is the pattern across most of my small packages, if I'm honest about it: I'm not solving problems nobody has thought of. Vite solved this exact problem for its own ecosystem years ago. I'm just porting the convenience to a tool that doesn't have it natively, because I like esbuild's speed enough to want the convenience anyway instead of switching bundlers.
What's real versus what's aspirational
The README is accurate here, which is worth saying because it isn't always true of my smaller packages: the envPlugin() export matches the docs, the options match the behavior. No ghost features, no promised API that doesn't exist. That's partly luck and partly because this plugin is simple enough that there wasn't much room for the code and the docs to drift apart while I was writing them.
There's no test suite, which I'd say about nearly everything in this batch of tools and I'm not going to keep apologizing for it: these are single-developer utilities built to solve a problem I actually had, not libraries built for a thousand unknown consumers with a thousand unknown edge cases. The test suite, again, is every build of every project that uses it. If the values come through wrong, my own app breaks first, and I notice before anyone else could.
Where this fits
I don't build tooling for the sake of having a tooling portfolio. Every one of these small packages exists because a specific project needed it and buying or importing a heavier solution felt like the wrong trade for something this narrow. esbuild-plugin-env came out of exactly one afternoon of being annoyed that my esbuild config had grown a define block nobody could read anymore. Now it's one line: plugins: [envPlugin({ prefix: 'PUBLIC_' })]. That's the whole return on investment. Not a business. Not a strategy. Just fewer minutes spent on something that was never the interesting part of the work.
The .d.ts feature I built for myself, specifically
I want to be precise about why the typed process.env output matters to me more than it probably should for something this small. TypeScript is only as useful as the types it actually has, and process.env by default is typed as { [key: string]: string | undefined }: every single key comes back possibly-undefined, forever, whether or not you know for a fact it's always set in your environment. That's technically correct and practically exhausting: every reference to process.env.API_URL needs a null check or a non-null assertion, even in code paths where you've already validated the variable exists. The generated .d.ts collapses that. Once esbuild-plugin-env knows your schema, it can tell TypeScript "these keys are guaranteed to exist, don't make me prove it every time," and every process.env.API_URL reference downstream just becomes a plain string, no assertion needed.
That's a small ergonomic win multiplied across every single environment variable reference in a codebase, which in a project the size of Nexli's client is genuinely a lot of references. It's the kind of improvement that doesn't show up in a demo (nobody screenshots "fewer non-null assertions") but shows up constantly in the actual experience of writing the code day to day.
Why I didn't just use Vite everywhere and skip this entirely
The honest answer is that not everything I build is a full application with a dev server and hot reload. Some of it is a small CLI tool, or a script that needs bundling into a single file for distribution, where spinning up Vite's whole dev-server machinery would be solving a problem I don't have in exchange for a problem I do have: slower cold starts, more configuration surface, more of Vite's opinions to work around for something that's fundamentally simple. esbuild's speed and minimalism are the right fit for that category of project specifically, and esbuild-plugin-env is what makes choosing esbuild for those projects not cost me the one convenience I'd otherwise miss.
Full stack developer. Founder of Yashveer Labs. This one's boring in exactly the way good infrastructure should be.
Start a conversation about this.
Whether it's esbuild-plugin-env itself or the next system worth building, the lab is reachable.