Node.js Developer Experience (DX) Web Architecture

Stop Installing What Node.js Already Gives You

Static Signal
A glowing toolbox opening to reveal an array of precision instruments, each representing a built-in Node.js capability replacing an external package

Run ls node_modules on any Node.js project that’s been around for more than six months. Count the top-level directories. Now open package.json and look at your dependencies. Half of them are polyfills, utilities, or wrappers for things that Node.js ships natively in 2026.

node-fetch. dotenv. glob. jest. nodemon. These packages solved real problems when they were written. Some of them defined entire workflows. But Node.js caught up — quietly, across a series of releases that most developers didn’t track because nobody announces “we added a flag” the way they announce a new framework.

The result is a runtime that’s dramatically more capable out of the box than the version most developers have a mental model of. If your mental model of Node.js was formed before v18, you’re carrying assumptions that cost you dependencies, build complexity, and startup time.

Here’s what you can uninstall.


Fetch Is Built In. Has Been Since v18.

The one that gets the most attention. node-fetch was downloaded 450 million times per week at its peak because Node.js didn’t have a global fetch. That changed in Node.js 18, where fetch became available globally without an import. It’s based on undici, which is faster than the old http module for most use cases.

// No import needed. This just works.
const res = await fetch("https://api.example.com/posts");
const data = await res.json();

Same API as the browser. Same Request, Response, and Headers objects. Same AbortController support for timeouts:

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);

const res = await fetch("https://api.example.com/slow-endpoint", {
  signal: controller.signal,
});

clearTimeout(timeout);

If you’re building a static site generator, a build script, or a Next.js API route — this is the fetch you’re already using. You just might still have node-fetch in your dependency tree because nobody removed it after the upgrade.

Check your package.json. If you see node-fetch and your engine requirement is >=18, it’s dead weight.


The Built-In Test Runner Is Production-Ready

This is the one that surprises people most. Node.js has a native test runner. Not an experimental curiosity — a real, stable test framework with describe, it, assertions, mocking, code coverage, and watch mode.

import { describe, it, mock } from "node:test";
import assert from "node:assert/strict";
import { slugify } from "../lib/utils.js";

describe("slugify", () => {
  it("converts spaces to hyphens", () => {
    assert.equal(slugify("hello world"), "hello-world");
  });

  it("lowercases the input", () => {
    assert.equal(slugify("Hello World"), "hello-world");
  });

  it("strips special characters", () => {
    assert.equal(slugify("what's up?"), "whats-up");
  });
});

Run it with node --test. That’s it. No config file. No transform pipeline. No jest.config.ts that imports three presets.

The mocking API covers the common cases:

import { describe, it, mock } from "node:test";
import assert from "node:assert/strict";

describe("data fetching", () => {
  it("handles API errors", async () => {
    const mockFetch = mock.fn(() =>
      Promise.resolve({ ok: false, status: 500 })
    );

    mock.method(globalThis, "fetch", mockFetch);

    // Your function that calls fetch internally
    const result = await fetchPosts();
    assert.equal(result, null);

    mock.restoreAll();
  });
});

Code coverage is a flag: node --test --experimental-test-coverage. Watch mode is a flag: node --test --watch. Parallel execution is the default. TAP output is available for CI integration.

Is it Jest? No. Jest has a larger ecosystem of matchers, a more expressive mocking API, snapshot testing, and deeper framework integrations. But for utility functions, API handlers, build scripts, and library code — the kind of testing that most Node.js projects actually need — the built-in runner is enough. And it starts in milliseconds instead of seconds because there’s no transform step.

The startup difference is not trivial. Jest needs to boot its transform pipeline, resolve its configuration, and initialize its worker pool before a single test runs. On a project with a jest.config.ts that imports ts-jest or @swc/jest, you’re looking at 2-4 seconds of overhead before the first test even executes. The built-in runner has no config phase. It reads the file, runs the tests, and reports the results. For a test suite of 50 utility tests, the wall-clock difference between “I saved the file” and “I see the result” is the difference between a tool you use constantly and one you context-switch away from.

The watch mode is worth calling out specifically. node --test --watch re-runs only the test files whose dependency graph changed. Modify a utility function and only its tests re-run. This is the behavior that Jest provides through --watchAll and --watch, but without the jest-haste-map file indexing that adds its own startup overhead.

For a blog build system, a static site’s content pipeline, or a set of API routes, the built-in test runner is not a compromise. It’s the right tool.


Environment Variables Without dotenv

dotenv is one of those packages that’s so ubiquitous it feels like part of the language. Almost every Node.js tutorial includes require('dotenv').config() on line one. It loads variables from a .env file into process.env. Simple, essential, and — since Node.js 20.6 — unnecessary.

node --env-file=.env server.js

That flag tells Node.js to load the .env file before your code runs. No package. No import. No config() call that needs to execute before anything else in your application. The variables are available in process.env by the time your first line of code executes.

It supports multiple files:

node --env-file=.env --env-file=.env.local server.js

Later files override earlier ones, which gives you the .env for defaults and .env.local for overrides pattern that dotenv required extra configuration to support.

The one limitation: it doesn’t do variable expansion (DB_URL=postgres://${DB_HOST}:${DB_PORT}). If you rely on that, you still need dotenv or dotenv-expand. But for the vast majority of projects where .env is a flat list of key-value pairs, the flag is a direct replacement.

In your package.json scripts:

{
  "scripts": {
    "dev": "node --env-file=.env.local next dev",
    "build": "node --env-file=.env next build",
    "test": "node --env-file=.env.test --test"
  }
}

Clean. No bootstrapping code. No race condition where something accesses process.env before dotenv has loaded.


File Watching Without nodemon

nodemon watches your files and restarts the process when something changes. It’s been the default development tool for Node.js servers since the early days. It’s also a dependency you no longer need.

node --watch server.js

That’s stable as of Node.js 22. It watches the file and its dependency graph. When you change server.js or any file it imports, the process restarts. No config file. No nodemon.json. No --ignore flags for node_modules (it handles that automatically).

There’s also --watch-path for more targeted watching:

node --watch-path=src --watch-path=config server.js

And --watch-preserve-output if you don’t want the terminal cleared on restart.

For development workflows on API servers, build scripts, or content processing pipelines, this replaces nodemon entirely.

The behavioral differences are minor but worth knowing. nodemon debounces rapid file changes by default — if you save three files in quick succession, it restarts once. Node.js --watch does the same. nodemon lets you configure the restart delay; --watch doesn’t. nodemon can run arbitrary commands (not just Node scripts); --watch is Node-only. These are edge cases. For the standard workflow of “I’m developing a Node.js server and I want it to restart when I change code,” the built-in flag is a complete replacement.

One less entry in devDependencies. One less config file. One less thing a new team member needs to understand before they can run the project.


Glob, Without the Package

File globbing — finding files that match patterns like **/*.md or src/**/*.tsx — used to require the glob or fast-glob package. Node.js 22 added fs.glob and fs.globSync:

import { glob } from "node:fs/promises";

// Find all markdown files in your content directory
const posts = [];
for await (const entry of glob("content/posts/**/*.md")) {
  posts.push(entry);
}

If you’re building a static site’s content pipeline — scanning a directory for markdown files, finding all images that need optimization, or listing templates that need compilation — this eliminates a dependency that almost every build script used to require.

The API is straightforward. It returns an async iterator, which means you can process files as they’re found rather than waiting for the entire glob to complete. For large directories, that’s a meaningful difference.

If you’ve built a content pipeline for a static blog — scanning content/posts/ for markdown, reading frontmatter, generating index pages — you’ve probably installed glob or fast-glob to do the file discovery. That dependency now has a built-in equivalent. The pattern matching supports the same syntax: * for single segments, ** for recursive traversal, {} for alternatives. The only difference is the return type — an async iterator instead of a promise that resolves to an array — which is arguably better for streaming use cases where you want to process files as they’re discovered.


Structured Argument Parsing

This one’s less well-known. node:util has parseArgs, a built-in argument parser:

import { parseArgs } from "node:util";

const { values, positionals } = parseArgs({
  options: {
    output: { type: "string", short: "o", default: "./dist" },
    watch: { type: "boolean", short: "w", default: false },
    verbose: { type: "boolean", short: "v", default: false },
  },
  allowPositionals: true,
  strict: true,
});

console.log(values.output); // "./dist" or whatever was passed
console.log(values.watch);  // true if --watch or -w was passed
console.log(positionals);   // ["file1.md", "file2.md"]

It handles short flags, long flags, boolean toggles, string values, defaults, and positional arguments. It doesn’t generate help text or do validation beyond type checking — for that you’d still reach for commander or yargs. But for build scripts, one-off CLIs, and internal tools where you control the interface, parseArgs is enough and it’s zero dependencies.

Every static site project ends up with at least one script that takes flags. A content processor that takes --draft to include draft posts. A build script that takes --output to change the target directory. These don’t need a CLI framework. They need parseArgs.


A Better Path API

Not new, but consistently underused. The node:path module got path.parse and path.format years ago, but most codebases are still doing string manipulation for file paths:

import path from "node:path";

// Instead of manual string splitting
const filePath = "content/posts/my-article.md";

const parsed = path.parse(filePath);
// { root: '', dir: 'content/posts', base: 'my-article.md',
//   ext: '.md', name: 'my-article' }

// Build a new path with a different extension
const htmlPath = path.format({ ...parsed, ext: ".html", base: "" });
// 'content/posts/my-article.html'

Combine with the built-in glob and you have a dependency-free content pipeline:

import { glob } from "node:fs/promises";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import path from "node:path";

for await (const file of glob("content/**/*.md")) {
  const content = await readFile(file, "utf-8");
  const parsed = path.parse(file);
  const outDir = path.join("dist", parsed.dir);
  const outPath = path.join(outDir, `${parsed.name}.html`);

  await mkdir(outDir, { recursive: true });
  await writeFile(outPath, transform(content));
}

No fs-extra. No mkdirp (that’s what { recursive: true } replaced). No glob package. Five lines of code, zero external dependencies, and it processes every markdown file in your content directory.


The Dependency Audit

Here’s a practical exercise. Open your package.json and check each dependency against this list:

PackageBuilt-in ReplacementSince
node-fetchGlobal fetchv18.0
undiciGlobal fetch (powered by undici)v18.0
dotenv--env-file flagv20.6
nodemon--watch flagv22.0
glob / fast-globfs.globv22.0
jest / vitest (for simple tests)node:testv20.0
minimist / yargs (for simple CLIs)util.parseArgsv18.3
mkdirpfs.mkdir({ recursive: true })v10.12
rimraffs.rm({ recursive: true })v14.14
uuidcrypto.randomUUID()v19.0
abort-controllerGlobal AbortControllerv15.4

Not every row will apply to your project. Some projects genuinely need Jest’s snapshot testing or yargs’s help text generation. The point isn’t to purge all dependencies on principle. It’s to stop installing packages for problems that the runtime solved while you weren’t looking.

The exercise is worth doing on every project at least once a year. Node.js ships major versions on a predictable cadence, and each one quietly absorbs functionality that used to live in userland. The packages don’t remove themselves. package.json is append-only unless someone actively cleans it. That someone should be you.


Why This Matters for the Static Web

If you’re building with Next.js, Astro, or any modern static-first framework, your build pipeline runs on Node.js. Your content processing runs on Node.js. Your API routes, your serverless functions, your development scripts — all Node.js.

Every unnecessary dependency in that chain is:

A security surface. Each package is code you didn’t write that runs with full access to your filesystem and network. The node_modules supply chain is the most actively targeted attack vector in the JavaScript ecosystem. Fewer dependencies means fewer things that can be compromised.

A maintenance burden. Packages need updates. Updates sometimes have breaking changes. Breaking changes need investigation and fixes. Built-in APIs are versioned with the runtime — when you upgrade Node.js, everything upgrades together.

A startup cost. Every require or import that resolves through node_modules is disk I/O and module resolution overhead. For serverless functions and edge workers where cold start time matters, fewer dependencies means faster boot.

A complexity tax on new contributors. A developer opening your project for the first time has to understand your dependency choices. If slugify lives in a utility file and tests run with node --test, that’s two things to learn. If slugify uses a custom package and tests run through Jest with a transform pipeline and a custom config — that’s a rabbit hole before they’ve even read your code.

This is especially true for static site projects, where the codebase should be one of the simplest things in a developer’s toolkit. The entire selling point of static architecture is reduced complexity — fewer moving parts, fewer runtime dependencies, fewer things that can break at 2 AM. That philosophy should extend to your build tooling. A content pipeline that depends on the Node.js standard library and nothing else is a content pipeline that works on any machine with the right Node version installed. No npm install surprises. No peer dependency conflicts. No Dependabot PRs for packages that exist only because the runtime didn’t have the feature when you started the project.


How to Actually Do It

Don’t rip everything out at once. That’s a recipe for a broken build and a bad afternoon. Instead:

Start with the obvious wins. If you have node-fetch and your Node.js version is 18+, remove it and update the imports. This is a mechanical change with no behavior difference. Same for abort-controller, mkdirp, and rimraf.

Move your tests incrementally. Pick one test file — ideally for a pure utility function with no complex mocks — and rewrite it using node:test. Run it. If the workflow feels right, migrate another. You don’t have to drop Jest in one commit.

Update your scripts. Replace nodemon server.js with node --watch server.js in your package.json. Replace the require('dotenv').config() call with --env-file=.env in your dev script. These are one-line changes that immediately remove a dependency.

Check your CI. Make sure your CI environment runs a Node.js version that supports the built-in features you’re adopting. This is the one place where the migration has a hard dependency — if your CI is pinned to Node 16, none of this applies until you upgrade.

The Node.js runtime in 2026 is not the Node.js runtime you learned. It’s better. And the best way to take advantage of that is to start by removing the things it replaced.


Static Signal is published by Neuron Web Development.