React SME Cookbook
All FAQs
basicsnodejs-scriptsexamplesnodejs

Node.js Scripts Basics

11 examples to get you started with Node.js Scripts -- 8 basic and 3 intermediate.

Prerequisites

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 higher

Conventions for every example below:

  1. Files live in a folder with a package.json. Run npm init -y once to create one.
  2. Sources use ES Modules (import/export). Set "type": "module" in package.json.
  3. TypeScript examples run with tsx -- no compile step needed during development.

Basic Examples

1. Verify Node and npm Are Installed

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).
  • If node is missing, install via nvm (macOS/Linux) or fnm/Volta (cross-platform) -- avoid the global Homebrew install on shared machines.
  • Pin the version with a .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


2. Hello World JavaScript Script

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
  • Any .js file with valid code runs directly under node -- no bundler, no config.
  • With "type": "module" in package.json, .js files are treated as ES Modules (import/export). Without it, they are CommonJS (require/module.exports).
  • Use 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


3. Run a TypeScript Script with tsx

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.ts
  • tsx uses esbuild under the hood -- fast startup and the same type-stripping behavior as production builds.
  • Use tsx watch sum.ts for a live-reload dev loop; use npx tsc only when you want to produce .js output for deployment.
  • Add @types/node so built-ins like fs, path, and process are typed.
  • Node 22.6+ ships native type stripping (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


4. Install Packages with npm or pnpm

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 tsx
  • package.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.
  • pnpm's content-addressable store dedupes copies across projects -- big disk and install-time wins on machines with many repos.
  • Commit exactly one lockfile: package-lock.json for npm, pnpm-lock.yaml for pnpm. Delete the other.
  • Use 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


5. ESM Imports

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));
  • Set "type": "module" in package.json so .js/.ts files are ESM by default.
  • ESM requires explicit file extensions in imports -- ./math.js even when the source is math.ts.
  • Top-level await is allowed in ESM: const data = await fetch(...) at the top of a file works.
  • Mixing CJS and ESM in the same file is painful -- pick one and stick with it for the project.

Related: ESM vs CommonJS -- interop rules, createRequire, and when CJS still makes sense


6. Read a File with fs/promises

Read and write files asynchronously -- the recommended API since Node 14.

// 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.
  • Always pass the encoding ("utf8"); omit it only if you want a Buffer.
  • Prefix built-in modules with node: (node:fs, node:path, node:url) -- unambiguous and works in both ESM and CJS.
  • For huge files or line-by-line work, use streams (createReadStream) or readline instead of buffering the whole file into memory.

Related: Utility Scripts -- file I/O, paths, globs, and script recipes


7. Parse CLI Arguments

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.
  • Returns values (parsed flags) and positionals (remaining args) -- both fully typed from the schema.
  • For larger CLIs with subcommands, help text, and prompts, graduate to commander or oclif.

Related: Utility Scripts -- CLI patterns, prompts, exit codes


8. EventEmitter

Pub/sub inside a single process -- the foundation for streams, HTTP servers, and custom events.

// 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.
  • Use the typed EventEmitter<T> generic (Node 22+) to get compile-time checking of event names and payloads.
  • Always unbind listeners with 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


Intermediate Examples

9. Minimal HTTP Server with node:http

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.
  • Call res.writeHead before res.end; res.end closes the connection.
  • For anything more than a health endpoint (routing, middleware, validation), reach for Express or Fastify.
  • Handle 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


10. Express Server with TypeScript

Familiar API, huge ecosystem -- the default choice for most Node.js services.

npm install express
npm install --save-dev @types/express
// 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.
  • Type the Request generics (Params, ResBody, ReqBody, Query) so handlers are fully typed end-to-end.
  • Use zod or valibot inside the handler to validate bodies instead of trusting the client.
  • For a faster alternative with built-in schema validation, use Fastify -- same mental model, better throughput.

Related: Making HTTP Servers -- Express vs Fastify vs node:http tradeoffs | ESLint for Node.js Scripts -- recommended rules for server code


11. ESLint Flat Config for TypeScript Scripts

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
  • Flat config is a single file that replaces .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".
  • Pair with a 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