Node.js File Handling, Environment Variables, and Scripts

Published Updated

Small Node automations rest on four skills: reading and writing files from a script, listing a folder and picking out the files you want, building paths that survive being run from the wrong place, and keeping secrets out of your code with environment variables.

These examples build on Node.js basics and familiar JavaScript. Save them as .mjs files, or use "type": "module" in package.json, so their imports and top-level await work as ES modules.

How Do You Read and Write Files in Node.js?

Use readFile to read a file and writeFile to replace its contents. Both are available from node:fs/promises, and the node: prefix says plainly that this is a built-in rather than something installed. Two functions cover most work:

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

await writeFile("notes.txt", "First line\nSecond line\n", "utf8");
const text = await readFile("notes.txt", "utf8");

console.log("characters read:", text.length);
console.log(text);
// node output:
// characters read: 23
// First line
// Second line

The "utf8" argument is what makes readFile return text. Leave it out and you get a buffer of bytes instead. Buffers are useful for binary data; decode them when you need text.

writeFile replaces the whole file and creates it if it is not there, so it never appends by accident. Use appendFile when adding to the end is what you want. Both are promises, which is why every line above is awaited, and the promises guide covers what await is doing.

When the File Is Not There

A missing file rejects the promise, so an unhandled one ends the script. Catching it lets you say something useful instead:

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

try {
  await readFile("does-not-exist.txt", "utf8");
} catch (error) {
  console.log("code:", error.code);
  console.log("message:", error.message);
}
// node output:
// code: ENOENT
// message: ENOENT: no such file or directory, open 'does-not-exist.txt'

That code property is the part to branch on. ENOENT means no such file, and it is a stable identifier, whereas the message wording is for humans and is not a contract.

Knowing the code lets you separate the case you expected from the case you did not. A missing configuration file might reasonably fall back to defaults, while a permission error should stop the script and say so.

Listing a Folder

readdir gives you the names inside a folder as plain strings, which array methods then filter:

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

const entries = await readdir("inbox");
console.log("everything:", entries);
console.log("markdown only:", entries.filter((name) => name.endsWith(".md")));
// node output:
// everything: [ 'notes.txt', 'one.md', 'two.md' ]
// markdown only: [ 'one.md', 'two.md' ]

Two details matter more than they look. The names are names, not paths, so joining them to the folder is your job before you can open one. And the order is whatever the file system reports, not a guaranteed alphabetical sort, so call sort() when the order is part of what you promise.

readdir also does not descend into subfolders unless you ask, with its recursive option, which was added in Node 18.17 and 20.1. Starting without it is usually the safer choice, because a script that edits files should be explicit about how far it reaches.

How Does Node.js Resolve Relative File Paths?

Node.js resolves relative file paths from the process’s current working directory. A path such as "inbox" is resolved against the folder the terminal is in, which is not necessarily the folder the script is in:

import { join, resolve, extname, basename } from "node:path";

console.log("cwd:", process.cwd());
console.log("this file's folder:", import.meta.dirname);
console.log("join:", join("inbox", "one.md"));
console.log("beside this file:", join(import.meta.dirname, "inbox", "one.md"));
console.log("extname:", extname("report.final.md"));
console.log("basename:", basename("inbox/one.md", ".md"));
// node output, run from inside the script's own folder:
// cwd: /private/tmp/example/fsdemo
// this file's folder: /private/tmp/example/fsdemo
// join: inbox/one.md
// beside this file: /private/tmp/example/fsdemo/inbox/one.md
// extname: .md
// basename: one

Run the identical file from one folder up and only the first line changes, to /private/tmp/example. The script's own folder stays put, which is exactly why it is the reliable anchor.

A script that reads "inbox" relatively fails from that second location, and the failure is the same ENOENT as a missing folder:

// node output, running the folder-listing script from one level up
// (working directory /private/tmp/example, script in /private/tmp/example/fsdemo):
// Error: ENOENT: no such file or directory, scandir 'inbox'
// exit code: 1

The rule that avoids all of this: build paths from import.meta.dirname when the target belongs to the script, and take them from an argument when the target belongs to whoever is running it. Use join rather than gluing strings together with slashes, since it handles the separators for you.

One boundary on that anchor. import.meta.dirname exists only in ES modules loaded over the file: protocol, arrived in Node 20.11 and 21.2, and stopped being experimental in 22.16 and 24.0. In CommonJS the equivalent is the long-standing __dirname.

How Do You Read Environment Variables in Node.js?

Read Node.js environment variables through process.env. Variables hold configuration supplied by the launching environment or a loaded .env file. Their values are strings, so convert and validate numbers or booleans before using them:

console.log("API key configured:", Boolean(process.env.API_KEY));
console.log("OUTPUT_DIR:", process.env.OUTPUT_DIR ?? "reports");
// node output, with nothing set:
// API key configured: false
// OUTPUT_DIR: reports

// and set for one run only, as: API_KEY=abc123 node env.js
// API key configured: true
// OUTPUT_DIR: reports

Keep real API keys out of logs and version control. A missing variable is undefined rather than an error, which is why the default in the second line uses ??. Setting one in front of the command applies to that run alone, and the next run sees nothing again.

That prefix form is POSIX shell syntax, so macOS and Linux only. PowerShell has no per-command prefix. Set $env:API_KEY = "abc123" first, and remove it afterwards with Remove-Item Env:\API_KEY if it should not linger in the session.

Typing them every time gets old, so Node reads a file of key and value pairs directly. No package is needed for this:

API_KEY=from-the-env-file
OUTPUT_DIR=build/reports
// node output, run as: node --env-file=.env env.js
// API key configured: true
// OUTPUT_DIR: build/reports

One behavior is worth knowing before it confuses you. A value already set in the environment wins over the file: running the same command with API_KEY=from-the-shell in front of it uses the shell value while still taking OUTPUT_DIR from the file.

That precedence is documented, and it lets the launching environment override a file-supplied value. You can also pass --env-file more than once, and later files override earlier ones.

--env-file arrived in Node 20.6. A missing file stops startup with an error rather than being ignored.

Use --env-file-if-exists when the file is optional, which prints a note and continues with the variable unset. It arrived in Node 22.9 and was backported to later Node 20 releases, so run node --help if you are on an older line and need to know. And add .env to your gitignore file before you put anything real in it.

A Complete Bulk Edit Script

Here is everything above in one job worth doing: rewriting insecure links across a folder of Markdown files. It takes the folder as an argument, supports a dry run, and reports what it did:

import { readdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";

const folder = process.argv[2];
const dryRun = process.argv.includes("--dry-run");

if (!folder) {
  console.error("Usage: node fix-links.js <folder> [--dry-run]");
  process.exit(1);
}

const entries = await readdir(folder, { withFileTypes: true });
const names = entries
  .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
  .map((entry) => entry.name);
let changed = 0;

for (const name of names.sort()) {
  const path = join(folder, name);
  const before = await readFile(path, "utf8");
  const after = before.replaceAll("http://codewalkers.dev", "https://codewalkers.dev");

  if (before === after) {
    console.log("unchanged:", name);
    continue;
  }

  changed += 1;
  if (dryRun) {
    console.log("would update:", name);
  } else {
    await writeFile(path, after, "utf8");
    console.log("updated:", name);
  }
}

console.log(`${changed} of ${names.length} files ${dryRun ? "would change" : "changed"}`);

Save this as fix-links.js in an ES-module project. You can add it to package.json as an npm script once it works. Run the preview first. Here the folder holds three files, two of them containing the old link:

// node output, run as: node fix-links.js posts --dry-run
// would update: first.md
// would update: second.md
// unchanged: third.md
// 2 of 3 files would change
// exit code: 0

The dry run leaves the files untouched. Running it without the flag rewrites them:

// node output, run as: node fix-links.js posts
// updated: first.md
// updated: second.md
// unchanged: third.md
// 2 of 3 files changed

// first.md before:  Read more at http://codewalkers.dev/start.
// first.md after:   Read more at https://codewalkers.dev/start.

Run it a second time and it reports all three unchanged. A script safe to run twice is a script you can rerun after fixing one file by hand.

Three habits are doing the work here, and they transfer to every automation you write. Fail loudly with a usage message and a non-zero exit code when the input is missing.

Compare before and after rather than writing unconditionally, so unchanged files stay untouched. And say what happened, per file and in total.

Pitfalls and Debugging

ENOENT on a path you can see in your editor. The likeliest cause is the terminal being somewhere else, since relative paths follow the terminal. Print process.cwd() at the top of the script to confirm before changing anything else.

readFile returned something that is not text. The encoding argument was left out, so you have a buffer. Pass "utf8", or call toString("utf8") on what came back.

writeFile emptied the file. It replaces the entire contents, so writing an empty string leaves an empty file. Passing undefined instead causes an error. Read, transform, then write, and use appendFile when adding to the end is the intent.

The script ate a file it should not have. The filter was too broad or reached deeper than expected. Run against a copy first, add the dry run before the write, and leave recursive off unless you meant it.

The env file was loaded and the value is still the old one. Something in the shell already set that name, and the existing value wins. Check which environment supplies the value without printing secret keys into logs.

The script exited without running and reported a missing env file. Check the filename after --env-file, or switch to the if-exists form when the file is optional.

Frequently Asked Questions

Why can a Node.js file script fail when run from another folder?

A Node.js script can fail from another folder because relative file paths use the process’s current working directory. Build paths from import.meta.dirname when the files belong beside an ES module, or accept an explicit path from the person running the script.

Should a Node.js .env file be committed?

Do not commit a Node.js .env file containing passwords, API keys, or other secrets. Add it to .gitignore before committing it. Share a .env.example file with variable names and placeholder values instead.

Does Node.js need a package to load .env files?

Modern Node.js versions can load .env files with --env-file, without an extra package. Existing environment variables take precedence, and later --env-file arguments override earlier files. Use additional tooling when you need features such as variable expansion or configuration validation.

Why should a Node.js file-editing script offer a dry run?

A dry run lets you inspect the files a Node.js script would change before it writes anything. It does not replace a backup or prove every edit is correct. Test bulk changes on a copy and inspect the differences before updating important files.

Sources

  1. [1]
    File System
    (nodejs.org)
  2. [2]
    Path
    (nodejs.org)
  3. [3]
    Command-Line API
    (nodejs.org)