Plain Node is Powerful Now
It turns out that plain node is super powerful in 2026. Node and npm have always been mixed bags of partial functionality (or lack thereof) but it seems like node has finally caught up to the competitors. Lately I've been switching a lot of my Deno/Bun/Yarn projects over to plain node and npm and I'm honestly blown away with how good node has gotten. We can finally get rid of most of our boilerplate dependencies!
ESM
Modern node means modern syntax. No one has to worry about ECMAScript modules vs CommonJS anymore. Since node v14, everything can be ESM by default with CJS backwards compatibility when "type": "module" is declared in package.json.
import mod from "xyz.js"; // ESM is inferred
import { namedExport } from "xyz";
import pkg from "xyz-old.cjs"; // backwards compat for CJS
export function myMod() {}
export const myVar = true;
We even have async-await APIs and top-level await!
import { namedExport } from "xyz";
import { writeFile } from "node:fs/promises";
const myMod = await import("./myMod.js");
await writeFile("output.txt", "hello", "utf-8");
Type-stripping
Node v22.18.0 supports type-stripping, allowing some projects to run TypeScript natively without a build step. This only works if the project has erasableSyntaxOnly. Node does not actually typecheck, it just ignores type syntax at runtime.
// ran natively with node main.ts
function add(x: number, y: number) {
return x + y;
}
add(4, 5);
We can also set up subpath imports in package.json to replace compilerOptions.paths TS aliases:
{
"type": "module",
"imports": {
"#/*": "./src/*"
},
}
import utils from "#/utils.ts";
utils.formatPrice("324");
Watching
Nodemon is now a tool of the past. Node v22 supports a --watch CLI option which automatically watches module changes and restarts when changes are detected.
-$ nodemon server.js
+$ node --watch server.js
Git Hooks
Husky has not been needed for node projects for a long time. Git hooks can be installed to the hooks folder manually without pulling in dependencies.
{
"scripts": {
- "prepare": "husky"
+ "prepare": "cp ./.husky/* ./.git/hooks/"
}
}
Modern APIs
Modern node comes with modern JavaScript APIs. In practice, this means simpler logic, less external dependencies, and code that works on both client and server. Here are some examples:
URL and query string parsing:
-import qs from "qs";
const queryString = "test=true&myParam=1234"
-const myParam = qs.parse(queryString).myParam;
+const myParam = new URLSearchParams(queryString).get("myParam");
-import { isUri } from "valid-url";
+function isUri(url: string) {
+ try {
+ new URL(url);
+ return true;
+ } catch {
+ return false;
+ }
+}
UUIDs:
-import { v4 } from "uuid";
-v4();
+crypto.randomUUID();
Basic utilities:
-import _ from "lodash";
const arr = ["hello", "world", "john"];
const obj = { key: "val" };
-const index = _.findIndex(arr, (val) => val === "world");
+const index = arr.findIndex((val) => val === "world");
-const newArr = _.concat(arr, "four", "five");
+const newArr = arr.concat("four", "five");
-const isEmpty = _.isEmpty(obj);
+const isEmpty = Object.keys(obj).length === 0;
-const chunks = _.chunk(arr, 2);
+let chunks = [];
+for (let i = 0; i < arr.length; i += 2) {
+ chunks.push(arr.slice(i, i + 2));
+}
Timezone date manipulation (node v26):
-import { TZDate } from "@date-fns/tz";
-import { addHours } from "date-fns";
const epoch = 1792969760096;
const tz = "Asia/Singapore";
-let date = new TZDate(epoch, tz);
-date = addHours(date, 2);
-console.log(date.toISOString());
+let date = new Temporal.ZonedDateTime(BigInt(epoch * 1_000_000), tz);
+date = date.add("PT2H");
+console.log(date.toString());
Security
Node has beefed up their security options too (I think partially due to the AI vulnpocalypse). We can now configure a lot of options yarn has provided out of the box for years:
allow-git = none # disable raw git sources for dependencies
ignore-scripts = true # disable postinstall and prepare scripts
min-release-age = 10 # only allow pkg versions older than 10 days
prefer-dedupe = true # dedupe sub and peer dependencies
save-exact = true # pin exact versions in package.json and lockfile
In CI you can fetch runtime-only dependencies and verify they are immutable with NODE_ENV=production npm ci. This reduces your attack surface and makes your code more reproducible.
Tests
Did you know that Node has its own test runner?
import { describe, it } from "node:test";
import { equal, deepStrictEqual } from "node:assert/strict";
describe("test suite", () => {
it("adds numbers", () => {
equal(2 + 4, 6);
});
it("checks arrays", () => {
const arr = [1, 2, 3];
arr.push(4);
deepStrictEqual(arr, [1, 2, 3, 4]);
});
});
It's not as feature-packed as Vitest or Jest but it works wonders for smaller projects.
Future Enhancements
In addition to the capabilities above, I'm also really excited for future enhancements. There's already partial support for a few "quality of life" features in upcoming releases and I'm really excited to see these roll out: