Working with the Node.js File System Module

The Node.js file system module (fs) gives your Express server the ability to read files, write files, create directories, and check if paths exist. In MERN development you will use the file system module more than you might expect โ€” reading .env files, writing logs, handling uploaded images saved to disk, serving static assets, and reading email templates from HTML files. In this lesson you will learn the modern fs/promises API, understand when to use it, and build practical patterns you will reuse throughout the series.

fs vs fs/promises โ€” Which to Use

API Style Use When
fs (callback) Old-style callbacks Legacy codebases โ€” avoid in new code
fs (Sync methods) Synchronous โ€” blocks event loop One-time startup tasks only (reading config before server starts)
fs/promises Promise-based โ€” non-blocking Everything else โ€” use this in Express route handlers and middleware
Note: require('fs/promises') is available from Node.js 14 onwards and gives you the same functions as fs but returning Promises instead of using callbacks. Combined with async/await this makes file operations read almost like synchronous code while remaining fully non-blocking. Always prefer fs/promises in Express route handlers.
Tip: Use path.join() when building file paths โ€” never concatenate strings manually with /. path.join(__dirname, 'uploads', filename) works correctly on both Windows (which uses backslashes) and Unix (which uses forward slashes). String concatenation breaks on one platform or the other.
Warning: Always validate file paths constructed from user input. A malicious user could send a filename like ../../.env (path traversal attack) to read sensitive files outside your intended directory. Use path.basename(userFilename) to strip any directory components from a user-supplied filename before using it in a file path.

Core fs/promises Operations

const fs   = require('fs/promises');
const path = require('path');

// โ”€โ”€ Read a file โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
async function readConfig() {
  try {
    const filePath = path.join(__dirname, 'config.json');
    const raw      = await fs.readFile(filePath, 'utf8');
    return JSON.parse(raw);
  } catch (err) {
    if (err.code === 'ENOENT') {
      console.error('Config file not found');
      return null;
    }
    throw err;
  }
}

// โ”€โ”€ Write a file โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
async function writeLog(message) {
  const logPath = path.join(__dirname, 'logs', 'app.log');
  const entry   = `[${new Date().toISOString()}] ${message}\n`;
  await fs.appendFile(logPath, entry, 'utf8');
}

// โ”€โ”€ Check if a file exists โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
async function fileExists(filePath) {
  try {
    await fs.access(filePath);
    return true;
  } catch {
    return false;
  }
}

// โ”€โ”€ Create a directory (and any missing parents) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
async function ensureUploadDir(dirPath) {
  await fs.mkdir(dirPath, { recursive: true });
  // recursive: true โ†’ no error if directory already exists
}

// โ”€โ”€ Delete a file โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
async function deleteUpload(filename) {
  const filePath = path.join(__dirname, 'uploads', path.basename(filename));
  await fs.unlink(filePath);
}

Reading Email Templates from Files

// A practical MERN use case โ€” loading HTML email templates from disk
// server/src/utils/emailTemplate.js

const fs   = require('fs/promises');
const path = require('path');

/**
 * Loads an HTML template file and replaces {{VARIABLE}} placeholders.
 * @param {string} templateName - filename without extension e.g. 'welcome'
 * @param {object} variables    - e.g. { NAME: 'Jane', LINK: 'https://...' }
 */
async function loadTemplate(templateName, variables = {}) {
  const templatePath = path.join(__dirname, '../templates', `${templateName}.html`);
  let html = await fs.readFile(templatePath, 'utf8');

  for (const [key, value] of Object.entries(variables)) {
    html = html.replaceAll(`{{${key}}}`, value);
  }

  return html;
}

module.exports = { loadTemplate };

Handling File Uploads โ€” Saving to Disk

// When Multer saves an uploaded file, you sometimes need to rename or move it
const fs   = require('fs/promises');
const path = require('path');

async function saveUploadedFile(tempPath, originalName, userId) {
  // Create user-specific upload directory
  const uploadDir = path.join(__dirname, '..', 'uploads', userId.toString());
  await fs.mkdir(uploadDir, { recursive: true });

  // Sanitise the filename โ€” remove directory traversal characters
  const safeName   = path.basename(originalName);
  const finalPath  = path.join(uploadDir, `${Date.now()}-${safeName}`);

  // Move from temp location to final destination
  await fs.rename(tempPath, finalPath);

  return finalPath;
}

path Module โ€” Essential Methods

Method Example Result
path.join() path.join(__dirname, 'uploads', file) Cross-platform path string
path.resolve() path.resolve('./src/config') Absolute path from cwd
path.basename() path.basename('/uploads/img.jpg') 'img.jpg'
path.extname() path.extname('photo.jpg') '.jpg'
path.dirname() path.dirname('/src/routes/posts.js') '/src/routes'
__dirname __dirname Absolute path of current file’s directory
__filename __filename Absolute path of current file

Common Mistakes

Mistake 1 โ€” Using fs.existsSync() in route handlers

โŒ Wrong โ€” synchronous existence check blocks the event loop:

if (fs.existsSync(uploadPath)) { // blocks!
  fs.unlinkSync(uploadPath);     // also blocks!
}

โœ… Correct โ€” use async versions:

try {
  await fs.access(uploadPath);   // throws if file does not exist
  await fs.unlink(uploadPath);   // delete it
} catch { /* file did not exist โ€” that is fine */ }

Mistake 2 โ€” Building paths with string concatenation

โŒ Wrong โ€” hardcoded forward slashes break on Windows:

const filePath = __dirname + '/uploads/' + filename; // breaks on Windows

โœ… Correct โ€” use path.join() for cross-platform compatibility:

const filePath = path.join(__dirname, 'uploads', filename); // works everywhere โœ“

Mistake 3 โ€” Not handling ENOENT errors

โŒ Wrong โ€” letting a missing file throw an unhandled error to the client:

const data = await fs.readFile(path);
// If file is missing โ†’ UnhandledPromiseRejection โ†’ server crash or 500 error

โœ… Correct โ€” check the error code and respond appropriately:

try {
  const data = await fs.readFile(filePath, 'utf8');
  res.json({ data });
} catch (err) {
  if (err.code === 'ENOENT') return res.status(404).json({ message: 'File not found' });
  next(err); // unexpected error โ€” pass to global error handler
}

Quick Reference

Task fs/promises code
Read file as string await fs.readFile(path, 'utf8')
Write file await fs.writeFile(path, data, 'utf8')
Append to file await fs.appendFile(path, data)
Delete file await fs.unlink(path)
Create directory await fs.mkdir(path, { recursive: true })
List directory await fs.readdir(path)
Check file exists await fs.access(path) (throws if missing)
Move / rename file await fs.rename(oldPath, newPath)
Get file info await fs.stat(path)

🧠 Test Yourself

A user uploads a file with the name ../../.env. Your code builds the save path as path.join(__dirname, 'uploads', userFilename). What security risk does this create and how do you fix it?