I Didn't Install dotenv: What Happened When We Rebuilt It From Scratch
Every Node.js developer has probably done this:
npm install dotenv
Need validation?
Install another package.
Need environment variable expansion?
Install another package.
It works. It's convenient. And most of the time, we never stop to think about what those packages are actually doing underneath.
For Zero Dependency 2026, we decided to ask a different question:
How much of this could we build ourselves using only Node.js?
So we built envkit — a zero-dependency environment configuration and validation tool using only Node.js built-in APIs.
The problem
A typical Node.js project might use packages for several parts of environment configuration:
- Reading
.envfiles - Expanding variables such as
${HOST} - Validating required configuration
- Providing useful command-line feedback
For our project, none of those external runtime packages were allowed.
That meant we couldn't simply install the solution.
We had to understand what the solution actually does.
What we decided to build
Our goal was to create a small command-line tool that could:
.env
↓
Parse
↓
Expand variables
↓
Validate
↓
Run the application
The main commands are:
envkit check
and:
envkit run -- node app.js
The first checks whether the environment configuration is valid.
The second validates the environment before starting the application.
Rebuilding the part I normally get from dotenv
Our first challenge was parsing .env.
A simple file looks easy:
PORT=3000
HOST=localhost
API_KEY=abc123
Reading the file itself wasn't difficult. Node's standard library already provides the filesystem functionality we needed.
The interesting part was everything around it.
We had to decide how our parser would handle:
- Blank lines
- Comments
- Whitespace
- Quoted values
- Escaped characters
- Invalid lines
- Multiline values
The lesson was simple:
Reading a file is easy. Correctly interpreting a configuration format is where the complexity begins.
Instead of importing a parser, we implemented that logic ourselves.
Then variable expansion looked easy
We wanted to support configuration such as:
HOST=localhost
PORT=3000
API_URL=http://${HOST}:${PORT}/api
The expected result is:
API_URL=http://localhost:3000/api
At first, this looked like a simple string replacement problem.
Then we considered:
A=${B}
B=${A}
A naive implementation can keep resolving the variables forever.
So we needed to track which variables were currently being resolved.
Instead of hanging, envkit can report:
Circular reference detected: A → B → A
This was one of the moments where rebuilding a familiar feature became much more interesting than simply using a package.
A feature that looked like "replace ${VARIABLE}" suddenly involved dependency resolution and cycle detection.
Rebuilding validation
The next part was environment validation.
We wanted developers to be able to describe requirements such as:
{
PORT: {
type: "number",
required: true
},
API_KEY: {
type: "string",
required: true
},
NODE_ENV: {
type: "enum",
values: ["development", "production"],
default: "development"
}
}
Then a configuration such as:
PORT=hello
should produce something useful:
✗ PORT must be a number
Instead of installing a validation library, we implemented the checks using JavaScript's built-in language features.
The surprising part wasn't checking a type.
The difficult part was designing good error messages.
A validator that simply says "invalid" isn't very helpful.
We wanted the developer to immediately understand:
- Which variable failed
- Why it failed
- What was expected
- Whether multiple variables were incorrect
The CLI
Once the individual pieces worked, we connected them through a command-line interface.
The intended workflow became:
envkit check
which performs:
Read .env
↓
Parse
↓
Expand
↓
Validate
↓
Show result
And:
envkit run -- node app.js
performs the same validation before starting the application.
If validation fails:
✗ Environment validation failed.
Application was not started.
If it succeeds:
✓ Environment valid
✓ Starting application...
That turned the project from a collection of small utilities into an actual developer tool.
The question of trust
One of the questions we kept coming back to was:
Why should someone trust our implementation instead of the packages we replaced?
The answer isn't "because we're better."
That would just replace one form of blind trust with another.
The more interesting idea is verifiable trust.
A developer can inspect our relatively small codebase and understand what it does.
There isn't a large dependency tree hiding behind the application.
There is no external runtime package that can silently become part of the application because another package pulled it in.
Our goal isn't to eliminate trust.
It's to make the trusted surface small enough to inspect.
What we learned
The biggest lesson wasn't that npm packages are bad.
They aren't.
Mature packages exist because developers have already spent years discovering and fixing edge cases.
Our experiment showed us why those abstractions are useful.
When you remove the package, you suddenly encounter the complexity it was hiding:
dotenv
↓
Parsing rules
dotenv-expand
↓
Variable resolution
↓
Circular references
Validation library
↓
Type checking
↓
Defaults
↓
Error reporting
The standard library gave us the primitives.
But the engineering decisions were ours.
What we don't claim
envkit is not intended to pretend that a few hundred lines of code can instantly replace years of work in mature production libraries.
We will document the features we support and the edge cases we intentionally don't support.
That honesty is part of the project.
A zero-dependency project shouldn't hide its limitations just to look impressive.
Proving the dependency claim
The final project should make the zero-dependency requirement easy to verify.
Our Node.js project has no third-party runtime dependencies.
Instead of asking a judge to trust our claim, we provide dependency proof and document the standard-library substitutions in STDLIB.md.
For example:
Package we would normally use → What we used instead
dotenv → Node filesystem + our parser
dotenv-expand → Our variable resolver
zod/envalid → Our validation engine
chalk → ANSI terminal escape sequences
Jest → node:test + node:assert
The point isn't simply to have an empty package.json.
The point is to explain what we built instead.
Final thoughts
Zero Dependency forced us to slow down and look underneath abstractions we normally take for granted.
We started with:
npm install dotenv
and ended up asking:
What does dotenv actually have to do?
Then we asked the same question about expansion, validation, and CLI tooling.
The result is envkit: a small Node.js developer tool built without third-party runtime dependencies.
The most valuable part wasn't removing the packages.
It was discovering why those packages exist in the first place.