React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

nodejsjavascriptcliscriptsnpm

Running Node.js Scripts in JavaScript

How to run plain JavaScript files with Node.js — direct execution, npm scripts, shebang lines, and parsing command-line arguments.

Recipe

Quick-reference recipe card — copy-paste ready.

# Run a script directly
node script.js
 
# Run with auto-restart on file changes (Node 18.11+)
node --watch script.js
 
# Run with environment variables
NODE_ENV=production node script.js
 
# Pass arguments to the script
node script.js --name Alice --verbose
// package.json — npm scripts
{
  "name": "my-cli",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "my-cli": "./bin/cli.js"
  },
  "scripts": {
    "start": "node script.js",
    "dev": "node --watch script.js",
    "cli": "node bin/cli.js"
  }
}
// bin/cli.js — with shebang
#!/usr/bin/env node
import { parseArgs } from 'node:util';
 
const { values } = parseArgs({
  options: {
    name: { type: 'string', short: 'n' },
    verbose: { type: 'boolean', short: 'v' },
  },
});
 
console.log(`Hello, ${values.name ?? 'world'}!`);
# Make the script executable (Unix/macOS only)
chmod +x bin/cli.js
 
# Then run it directly
./bin/cli.js --name Alice

When to reach for this: Build scripts, automation tasks, one-off utilities, CLI tools, or anything you don't want to ship through a browser.

Working Example

A complete file-renamer CLI that lowercases filenames in a directory.

#!/usr/bin/env node
// bin/rename-lower.js
import { readdir, rename } from 'node:fs/promises';
import { join } from 'node:path';
import { parseArgs } from 'node:util';
 
const { values, positionals } = parseArgs({
  options: {
    dry: { type: 'boolean', short: 'd' },
    help: { type: 'boolean', short: 'h' },
  },
  allowPositionals: true,
});
 
if (values.help || positionals.length === 0) {
  console.log('Usage: rename-lower <dir> [--dry]');
  process.exit(values.help ? 0 : 1);
}
 
const dir = positionals[0];
 
try {
  const files = await readdir(dir);
  for (const file of files) {
    const lower = file.toLowerCase();
    if (file === lower) continue;
    const from = join(dir, file);
    const to = join(dir, lower);
    if (values.dry) {
      console.log(`[dry] ${from} -> ${to}`);
    } else {
      await rename(from, to);
      console.log(`renamed ${from} -> ${to}`);
    }
  }
} catch (err) {
  console.error('Error:', err.message);
  process.exit(1);
}
// package.json
{
  "name": "rename-lower",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "rename-lower": "./bin/rename-lower.js"
  }
}
# Try it locally
npm link
rename-lower ./photos --dry
rename-lower ./photos

What this demonstrates:

  • Shebang line so the file runs as a standalone executable
  • node:util parseArgs for zero-dependency argv parsing
  • Positional and flag arguments together
  • Graceful error handling with non-zero exit codes
  • bin field in package.json so npm link exposes the command globally

Deep Dive

How It Works

When you type node script.js, Node.js:

  1. Reads the file from disk.
  2. Determines the module type — ESM if "type": "module" in package.json or the file ends in .mjs, otherwise CommonJS.
  3. Compiles and evaluates the top-level module.
  4. Exposes the globals process, console, Buffer, and friends.
  5. Exits when the event loop is empty — or immediately if you call process.exit().

A shebang line like #!/usr/bin/env node tells the operating system's loader which interpreter to use when the file is executed directly. The env tool looks up node in PATH, which is more portable than a hard-coded /usr/local/bin/node.

process.argv is an array where index 0 is the Node.js binary path, index 1 is the script path, and index 2+ are the user-supplied arguments. That is why parseArgs skips the first two by default.

Variations

# Pass arguments through an npm script (note the --)
npm run cli -- --name Alice
 
# Inline environment variables
API_KEY=abc node script.js
 
# Watch mode (Node 18.11+)
node --watch script.js
 
# Watch mode with a specific entry
node --watch --watch-path=./src script.js
 
# Inspect/debug mode
node --inspect-brk script.js
 
# Parse args with the built-in utility
node -e "console.log(require('node:util').parseArgs({options:{n:{type:'string'}}}))" -- -n hi

TypeScript Notes

Even for plain JavaScript scripts, you can opt into type-checking without adding a build step by enabling // @ts-check at the top of the file and creating a jsconfig.json:

// jsconfig.json
{
  "compilerOptions": {
    "checkJs": true,
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "lib": ["ES2022"]
  },
  "include": ["bin/**/*.js", "scripts/**/*.js"]
}

Add JSDoc types for parameters and the editor — and tsc --noEmit — will catch mistakes:

// @ts-check
/** @param {string} name @returns {string} */
function greet(name) {
  return `Hello, ${name}!`;
}

Gotchas

  1. Missing shebang. Without #!/usr/bin/env node, executing the file directly (./script.js) on Unix shells errors with "exec format error" or runs the file as a shell script.
  2. Forgetting chmod +x. A correct shebang is useless if the file is not executable. Permission must be added with chmod +x bin/cli.js and the bit committed to Git (git update-index --chmod=+x).
  3. ESM vs CJS extension confusion. A .js file in a package with "type": "module" is ESM; the same file in a CJS package is CommonJS. Use .mjs/.cjs to be explicit and avoid ambiguity.
  4. __dirname is undefined in ESM. In ESM scripts you must reconstruct it: const __dirname = path.dirname(fileURLToPath(import.meta.url)).
  5. Windows line endings in scripts. A shebang with CRLF (\r\n) fails on Linux/macOS with "bad interpreter". Force LF via .gitattributes: *.js text eol=lf.
  6. Forgetting -- in npm scripts. npm run cli --name Alice passes --name Alice to npm, not your script. Use npm run cli -- --name Alice.
  7. process.argv indexing. Index 0 and 1 are the Node binary and the script path — your user args start at index 2.

Alternatives

ToolStrengthsWeaknesses
nodeBuilt in, zero config, widely supportedNo TS, no watch mode before 18.11
bunFastest startup, bundled, runs TS/JSXNewer ecosystem, some API gaps
denoSecure by default, native TS, standard libraryDifferent module resolution, smaller ecosystem
tsxFast TS/ESM runner on top of NodeExtra dependency
pnpm execRuns local binaries without global installsJust a runner, not a runtime

FAQs

What is the difference between node script.js and ./script.js?

node script.js explicitly invokes the Node.js binary. ./script.js relies on the operating system to read the shebang line and pick the interpreter — so it only works if the file starts with #!/usr/bin/env node and has the execute bit set.

How do I access command-line arguments inside a script?

Use process.argv, which is an array starting with the Node binary (index 0) and the script path (index 1). User arguments start at index 2. For anything beyond trivial flags, use parseArgs from node:util.

What does the "bin" field in package.json do?

It maps a command name to a script file. When the package is installed globally — or linked via npm link — npm creates a symlink in its bin directory so you can run the command from anywhere.

How do I run a script whenever a file changes?

Use Node's built-in --watch flag (Node 18.11+): node --watch script.js. For more control, pair it with --watch-path=./src to restrict the watched directories.

How do I pass arguments through an npm script? (gotcha)

You must separate the npm flags from your script flags with --. For example: npm run cli -- --name Alice. Without the double dash, npm consumes the arguments itself.

Why does my shebang script fail with "bad interpreter"? (gotcha)

Almost always Windows line endings. The shell reads the shebang line including the trailing \r and tries to run /usr/bin/env node\r, which does not exist. Force LF endings for shell and script files via .gitattributes.

Can I type-check plain JavaScript without TypeScript? (typescript)

Yes. Add // @ts-check to the top of a .js file, create a jsconfig.json with "checkJs": true, and annotate with JSDoc. Running tsc --noEmit surfaces type errors with no runtime footprint.

How do I describe function types in JSDoc for a JavaScript script? (typescript)

Use JSDoc tags like /** @param {string} name @returns {Promise<void>} */. Editors and tsc understand these annotations the same way they understand TypeScript types.

What is the difference between process.env and a .env file?

process.env is the in-memory environment variables the Node.js process inherited. A .env file is just a text file — Node.js does not parse it automatically. Use --env-file=.env (Node 20.6+) or a library like dotenv.

How should I return a non-zero exit code from a script?

Call process.exit(1) after logging the error, or throw an uncaught error and let Node exit with code 1 automatically. Reserve non-zero codes for real failures so shell pipelines and CI systems can detect them.

What is the global __dirname and why is it missing in some scripts?

__dirname is a CommonJS-only global that points at the directory of the current script. In ESM you reconstruct it from import.meta.url with fileURLToPath and path.dirname.

Can I use top-level await in a plain JavaScript script?

Only in ESM — that is, a .mjs file or a file in a package with "type": "module". In CommonJS you must wrap async code inside an async IIFE.