Node.js Scripts Basics
11 examples to get you started with Node.js Scripts -- 8 basic and 3 intermediate.
Search across all documentation pages
11 examples to get you started with Node.js Scripts -- 8 basic and 3 intermediate.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
You need Node.js 22 LTS or newer. Check with node --version; install via nvm, fnm, or Volta if you do not have it.
# Install or switch to the current LTS with nvm
nvm install --lts
nvm use --lts
node --version # should print v22.x or higherConventions for every example below:
package.json. Run npm init -y once to create one.import/export). Set "type": "module" in package.json.tsx -- no compile step needed during development.A one-liner to confirm your toolchain before you write any scripts.
node --version # v22.15.0 or similar
npm --version # 10.x or similar
node -e "console.log('hello from node', process.version)"node -e "<code>" runs a snippet inline -- perfect for smoke tests without creating a file.process.version reports the running Node version so you catch wrong-version issues immediately (e.g., old PATH picking up an old binary).node is missing, install via nvm (macOS/Linux) or fnm/Volta (cross-platform) -- avoid the global Homebrew install on shared machines..nvmrc (or .node-version) file so every contributor runs the same runtime.Related: Installing Node.js and npm -- nvm, fnm, Volta, and the LTS cadence
The smallest useful .js file that you can run with node.
// hello.js
const greeting = "Hello, Node.js!";
console.log(greeting);Run it:
node hello.js.js file with valid code runs directly under node -- no bundler, no config."type": "module" in package.json, .js files are treated as ES Modules (import/export). Without it, they are CommonJS (require/module.exports).node --watch hello.js during development to re-run on save (Node 18+ built-in).process.argv holds CLI arguments; process.env holds env vars -- both are always available.Related: Running JavaScript scripts -- entry points, watch mode, shebang lines | ESM vs CommonJS -- picking the module system
Skip the compile step in development -- tsx runs .ts files directly.
npm install --save-dev tsx typescript @types/node// sum.ts
function sum(a: number, b: number): number {
return a + b;
}
console.log(sum(2, 3));Run it:
npx tsx sum.tstsx uses esbuild under the hood -- fast startup and the same type-stripping behavior as production builds.tsx watch sum.ts for a live-reload dev loop; use npx tsc only when you want to produce .js output for deployment.@types/node so built-ins like fs, path, and process are typed.node --experimental-strip-types sum.ts) -- a good fallback when you cannot add tsx.Related: Running TypeScript Scripts -- tsx vs ts-node vs native stripping | ESLint for Node.js Scripts -- wiring lint to a TS script project
Both tools use the same package.json; pnpm is faster and disk-efficient.
# npm
npm install zod
npm install --save-dev tsx
# pnpm (enable via Corepack, no global install needed)
corepack enable
pnpm add zod
pnpm add -D tsxpackage.json is the source of truth -- both tools read/write it. Switching between them is safe if your team agrees on one at a time.package-lock.json for npm, pnpm-lock.yaml for pnpm. Delete the other.corepack to pin the exact package manager version in packageManager so CI and laptops stay aligned.Related: pnpm vs npm -- workspaces, hoisting, and performance comparison
Use the modern module system -- static imports, top-level await, named exports.
// package.json
{
"type": "module"
}// math.ts
export function sum(a: number, b: number) {
return a + b;
}// app.ts
import { sum } from "./math.js";
console.log(sum(2, 3));"type": "module" in package.json so .js/.ts files are ESM by default../math.js even when the source is math.ts.await is allowed in ESM: const data = await fetch(...) at the top of a file works.Related: ESM vs CommonJS -- interop rules,
createRequire, and when CJS still makes sense
Read and write files asynchronously -- the recommended API since Node 14.
// read-config.js
import { readFile, writeFile } from "node:fs/promises";
const text = await readFile("config.json", "utf8");
const config = JSON.parse(text);
config.apiUrl = config.apiUrl.replace("http:", "https:");
await writeFile("config.json", JSON.stringify(config, null, 2));// read-config.ts
import { readFile, writeFile } from "node:fs/promises";
const text = await readFile("config.json", "utf8");
const config = JSON.parse(text) as { apiUrl: string };
config.apiUrl = config.apiUrl.replace("http:", "https:");
await writeFile("config.json", JSON.stringify(config, null, 2));fs/promises returns Promises -- pair with top-level await for clean scripts."utf8"); omit it only if you want a Buffer.node: (node:fs, node:path, node:url) -- unambiguous and works in both ESM and CJS.createReadStream) or readline instead of buffering the whole file into memory.Related: Utility Scripts -- file I/O, paths, globs, and script recipes
Use node:util's parseArgs -- built-in, typed, no extra dependency.
// greet.ts
import { parseArgs } from "node:util";
const { values } = parseArgs({
options: {
name: { type: "string", short: "n", default: "world" },
shout: { type: "boolean", short: "s", default: false },
},
});
const greeting = `Hello, ${values.name}!`;
console.log(values.shout ? greeting.toUpperCase() : greeting);Run:
npx tsx greet.ts --name Ada --shout
# HELLO, ADA!parseArgs is built into Node 18+ -- no need for yargs or commander for most scripts.short: "n" lets callers pass -n Ada instead of --name Ada.values (parsed flags) and positionals (remaining args) -- both fully typed from the schema.commander or oclif.Related: Utility Scripts -- CLI patterns, prompts, exit codes
Pub/sub inside a single process -- the foundation for streams, HTTP servers, and custom events.
// pubsub.js
import { EventEmitter } from "node:events";
const bus = new EventEmitter();
bus.on("login", (userId) => {
console.log(`User ${userId} logged in`);
});
bus.emit("login", "u_123");
bus.emit("login", "u_456");// pubsub.ts
import { EventEmitter } from "node:events";
interface Events {
login: [userId: string];
logout: [userId: string];
}
const bus = new EventEmitter<Events>();
bus.on("login", (userId) => {
console.log(`User ${userId} logged in`);
});
bus.emit("login", "u_123");
bus.emit("login", "u_456");emit(name, ...args) calls every listener registered with on(name, handler) in registration order.EventEmitter<T> generic (Node 22+) to get compile-time checking of event names and payloads.off(name, handler) in long-running processes -- forgotten listeners leak memory.on adds a persistent listener; once fires then removes itself -- pick the right one to avoid duplicate handlers.Related: EventEmitter Patterns -- typed events, leaks, async iteration
Zero dependencies -- serve JSON from the built-in http module.
// server.ts
import { createServer } from "node:http";
const server = createServer((req, res) => {
if (req.url === "/health") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true, uptime: process.uptime() }));
return;
}
res.writeHead(404).end("Not Found");
});
const port = Number(process.env.PORT ?? 3000);
server.listen(port, () => {
console.log(`http://localhost:${port}`);
});createServer returns an EventEmitter -- you can also listen to "request", "connection", "close" directly.res.writeHead before res.end; res.end closes the connection.process.on("SIGTERM", () => server.close(...)) so container orchestrators can shut you down gracefully.Related: Making HTTP Servers -- node:http, Express, and Fastify side by side
Familiar API, huge ecosystem -- the default choice for most Node.js services.
npm install express
npm install --save-dev @types/express// app.js
import express from "express";
const app = express();
app.use(express.json());
app.post("/users", (req, res) => {
const { email } = req.body;
if (!email?.includes("@")) {
return res.status(400).json({ error: "Invalid email" });
}
res.status(201).json({ id: crypto.randomUUID(), email });
});
app.listen(3000, () => console.log("http://localhost:3000"));// app.ts
import express, { type Request, type Response } from "express";
const app = express();
app.use(express.json());
interface CreateUserBody {
email: string;
}
app.post("/users", (req: Request<{}, unknown, CreateUserBody>, res: Response) => {
const { email } = req.body;
if (!email?.includes("@")) {
return res.status(400).json({ error: "Invalid email" });
}
res.status(201).json({ id: crypto.randomUUID(), email });
});
app.listen(3000, () => console.log("http://localhost:3000"));express.json() parses application/json request bodies -- without it, req.body is undefined.Request generics (Params, ResBody, ReqBody, Query) so handlers are fully typed end-to-end.zod or valibot inside the handler to validate bodies instead of trusting the client.Related: Making HTTP Servers -- Express vs Fastify vs node:http tradeoffs | ESLint for Node.js Scripts -- recommended rules for server code
Modern ESLint 9+ uses eslint.config.js (flat config) -- leaner, faster, type-aware.
npm install --save-dev \
eslint typescript-eslint @eslint/js globals// eslint.config.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import globals from "globals";
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
parserOptions: { projectService: true },
globals: { ...globals.node },
},
rules: {
"no-console": ["warn", { allow: ["warn", "error"] }],
},
},
);Run:
npx eslint .
npx eslint . --fix.eslintrc.* and .eslintignore -- much less to learn.recommendedTypeChecked turns on type-aware rules (needs projectService: true), which catch unsafe any and promise misuse.globals.node teaches ESLint about process, Buffer, __dirname, etc., so they do not trigger "no-undef".lint npm script so CI can fail fast on style and type errors.Related: ESLint for Node.js Scripts -- rule recommendations, type-aware config, ignore patterns | ESLint Setup (Linting & Formatting) -- broader ESLint reference
Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥