Episode 3 — NodeJS MongoDB Backend Architecture / 3.7 — Handling Files with Express

3.7.b — File Upload with Multer

In one sentence: This section walks through complete, working file upload examples — from the HTML form and FormData API on the frontend, through Multer middleware on the backend, to accessing file metadata via req.file and req.files, custom filenames, organized folder structures, and graceful error handling.


Table of Contents


1. Complete Single File Upload Example

Step 1: Project setup

mkdir file-upload-demo && cd file-upload-demo
npm init -y
npm install express multer
mkdir uploads

Step 2: HTML form (public/index.html)

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Single File Upload</title>
</head>
<body>
  <h1>Upload Your Avatar</h1>

  <!-- enctype="multipart/form-data" is REQUIRED for file uploads -->
  <form action="/api/upload" method="POST" enctype="multipart/form-data">
    <label>
      Username:
      <input type="text" name="username" required />
    </label>
    <br /><br />

    <label>
      Avatar:
      <input type="file" name="avatar" accept="image/*" required />
    </label>
    <br /><br />

    <button type="submit">Upload</button>
  </form>
</body>
</html>

Step 3: Express server (server.js)

const express = require('express');
const multer = require('multer');
const path = require('path');

const app = express();

// Serve the HTML form
app.use(express.static('public'));

// Configure Multer — simple version
const upload = multer({
  dest: 'uploads/',
  limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB max
  fileFilter: (req, file, cb) => {
    if (file.mimetype.startsWith('image/')) {
      cb(null, true);
    } else {
      cb(new Error('Only image files are allowed'), false);
    }
  }
});

// Single file upload route
app.post('/api/upload', upload.single('avatar'), (req, res) => {
  // req.file contains the uploaded file info
  // req.body contains any text fields
  console.log('File:', req.file);
  console.log('Body:', req.body);

  res.json({
    message: 'Upload successful!',
    username: req.body.username,
    file: {
      originalName: req.file.originalname,
      size: req.file.size,
      mimetype: req.file.mimetype,
      path: req.file.path
    }
  });
});

// Global error handler
app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    return res.status(400).json({ error: `Upload error: ${err.message}` });
  }
  if (err) {
    return res.status(400).json({ error: err.message });
  }
  next();
});

app.listen(3000, () => console.log('Server running on http://localhost:3000'));

Step 4: Test it

node server.js
# Open http://localhost:3000 in your browser
# Fill the form and submit

2. The req.file Object — Every Property Explained

When you use upload.single('fieldName'), Multer populates req.file with this object:

{
  fieldname: 'avatar',              // name attribute in the HTML form
  originalname: 'profile-photo.jpg', // original filename on the user's computer
  encoding: '7bit',                 // file encoding (almost always '7bit')
  mimetype: 'image/jpeg',          // MIME type reported by the browser
  size: 245678,                    // file size in bytes
  // --- Disk storage properties ---
  destination: 'uploads/',          // folder where the file was saved
  filename: 'a1b2c3d4e5f6g7h8',   // generated filename on disk (no extension with dest)
  path: 'uploads/a1b2c3d4e5f6g7h8', // full path to the saved file
  // --- Memory storage property (instead of above three) ---
  buffer: <Buffer ff d8 ff e0 ...>  // file content as a Buffer (only with memoryStorage)
}

Property reference table

PropertyStorageDescription
fieldnameBothForm field name (from name attribute)
originalnameBothUser's original filename
encodingBothFile encoding (usually 7bit)
mimetypeBothMIME type (image/jpeg, application/pdf, etc.)
sizeBothFile size in bytes
destinationDisk onlyDirectory where file was saved
filenameDisk onlyName of file in the destination
pathDisk onlyFull path: destination + filename
bufferMemory onlyFile content as a Node.js Buffer

Tip: Use (req.file.size / 1024).toFixed(2) + ' KB' or (req.file.size / (1024 * 1024)).toFixed(2) + ' MB' to display human-readable sizes.


3. Complete Multiple File Upload Example

HTML form with multiple files

<form action="/api/gallery" method="POST" enctype="multipart/form-data">
  <label>
    Album Name:
    <input type="text" name="albumName" required />
  </label>
  <br /><br />

  <!-- "multiple" attribute allows selecting multiple files -->
  <label>
    Photos (up to 10):
    <input type="file" name="photos" multiple accept="image/*" required />
  </label>
  <br /><br />

  <button type="submit">Upload Gallery</button>
</form>

Express route

// upload.array('fieldName', maxCount)
app.post('/api/gallery', upload.array('photos', 10), (req, res) => {
  // req.files is an ARRAY of file objects
  console.log(`Received ${req.files.length} files`);
  console.log('Album:', req.body.albumName);

  const filesSummary = req.files.map(file => ({
    originalName: file.originalname,
    size: file.size,
    mimetype: file.mimetype
  }));

  res.json({
    message: `${req.files.length} files uploaded successfully`,
    album: req.body.albumName,
    files: filesSummary
  });
});

Multiple fields example

<form action="/api/profile" method="POST" enctype="multipart/form-data">
  <input type="text" name="displayName" />
  <label>Avatar: <input type="file" name="avatar" /></label>
  <label>Cover Photo: <input type="file" name="cover" /></label>
  <button type="submit">Save Profile</button>
</form>
const profileUpload = upload.fields([
  { name: 'avatar', maxCount: 1 },
  { name: 'cover', maxCount: 1 }
]);

app.post('/api/profile', profileUpload, (req, res) => {
  // req.files is an OBJECT keyed by field name
  const avatar = req.files['avatar'] ? req.files['avatar'][0] : null;
  const cover = req.files['cover'] ? req.files['cover'][0] : null;

  res.json({
    displayName: req.body.displayName,
    avatar: avatar ? avatar.originalname : 'No avatar uploaded',
    cover: cover ? cover.originalname : 'No cover uploaded'
  });
});

4. req.files — Array vs Object

The shape of req.files depends on which upload method you use:

┌──────────────────────────────────────────────────────────────┐
│  upload.array('photos', 10)                                  │
│                                                              │
│  req.files = [                                               │
│    { fieldname: 'photos', originalname: 'cat.jpg', ... },    │
│    { fieldname: 'photos', originalname: 'dog.jpg', ... },    │
│    { fieldname: 'photos', originalname: 'bird.jpg', ... }    │
│  ]                                                           │
│                                                              │
│  Access: req.files[0], req.files[1], req.files.length        │
└──────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────┐
│  upload.fields([{name: 'avatar'}, {name: 'gallery'}])        │
│                                                              │
│  req.files = {                                               │
│    avatar: [                                                 │
│      { fieldname: 'avatar', originalname: 'me.jpg', ... }    │
│    ],                                                        │
│    gallery: [                                                │
│      { fieldname: 'gallery', originalname: 'pic1.jpg', ... },│
│      { fieldname: 'gallery', originalname: 'pic2.jpg', ... } │
│    ]                                                         │
│  }                                                           │
│                                                              │
│  Access: req.files['avatar'][0], req.files['gallery'].length │
└──────────────────────────────────────────────────────────────┘
Methodreq.files TypeAccess Pattern
upload.array()Arrayreq.files[0], req.files.forEach(...)
upload.fields()Objectreq.files['fieldName'][0]
upload.any()ArraySame as array(), but any field name

5. Custom Filenames with diskStorage

The dest shortcut generates random filenames without extensions. For human-readable, unique filenames, use diskStorage:

const path = require('path');
const crypto = require('crypto');

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, 'uploads/');
  },
  filename: (req, file, cb) => {
    // Strategy 1: Timestamp + original name
    const uniqueName = Date.now() + '-' + file.originalname;
    cb(null, uniqueName);
    // Result: 1699123456789-profile.jpg
  }
});

// Strategy 2: Random hex + original extension
const storage2 = multer.diskStorage({
  destination: (req, file, cb) => cb(null, 'uploads/'),
  filename: (req, file, cb) => {
    const ext = path.extname(file.originalname);    // .jpg
    const randomName = crypto.randomBytes(16).toString('hex'); // 32-char hex
    cb(null, randomName + ext);
    // Result: a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5.jpg
  }
});

// Strategy 3: UUID-style (requires uuid package)
// npm install uuid
const { v4: uuidv4 } = require('uuid');

const storage3 = multer.diskStorage({
  destination: (req, file, cb) => cb(null, 'uploads/'),
  filename: (req, file, cb) => {
    const ext = path.extname(file.originalname);
    cb(null, uuidv4() + ext);
    // Result: 550e8400-e29b-41d4-a716-446655440000.jpg
  }
});

const upload = multer({ storage });

Why custom filenames matter: Two users could upload files named photo.jpg. Without unique filenames, the second upload overwrites the first.


6. Organizing Uploads by Date or User

By date (year/month)

const fs = require('fs');

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    const now = new Date();
    const year = now.getFullYear();
    const month = String(now.getMonth() + 1).padStart(2, '0');
    const dir = `uploads/${year}/${month}`;

    // Create directory if it does not exist
    fs.mkdirSync(dir, { recursive: true });
    cb(null, dir);
    // Result: uploads/2025/04/
  },
  filename: (req, file, cb) => {
    cb(null, Date.now() + '-' + file.originalname);
  }
});

By user ID (requires authentication)

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    // req.user is populated by your auth middleware
    const userId = req.user?.id || 'anonymous';
    const dir = `uploads/users/${userId}`;

    fs.mkdirSync(dir, { recursive: true });
    cb(null, dir);
    // Result: uploads/users/64a1b2c3d4e5/
  },
  filename: (req, file, cb) => {
    const ext = path.extname(file.originalname);
    cb(null, `${file.fieldname}-${Date.now()}${ext}`);
    // Result: avatar-1699123456789.jpg
  }
});

By file type

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    let folder = 'uploads/others';
    if (file.mimetype.startsWith('image/')) folder = 'uploads/images';
    else if (file.mimetype.startsWith('video/')) folder = 'uploads/videos';
    else if (file.mimetype === 'application/pdf') folder = 'uploads/documents';

    fs.mkdirSync(folder, { recursive: true });
    cb(null, folder);
  },
  filename: (req, file, cb) => {
    cb(null, Date.now() + path.extname(file.originalname));
  }
});

7. Frontend — Using FormData API with fetch and axios

In modern applications, you rarely use HTML form submissions. Instead, you build FormData objects in JavaScript and send them via fetch or axios.

Using fetch

// Select the file input and form fields
const fileInput = document.querySelector('#fileInput');
const usernameInput = document.querySelector('#username');

async function uploadFile() {
  const formData = new FormData();

  // Append file — key must match Multer's expected field name
  formData.append('avatar', fileInput.files[0]);

  // Append text fields
  formData.append('username', usernameInput.value);

  try {
    const response = await fetch('/api/upload', {
      method: 'POST',
      body: formData
      // DO NOT set Content-Type header — the browser sets it
      // automatically with the correct boundary string
    });

    const data = await response.json();
    console.log('Upload response:', data);
  } catch (error) {
    console.error('Upload failed:', error);
  }
}

Using axios

// npm install axios (frontend bundled) or CDN
import axios from 'axios';

async function uploadWithAxios() {
  const formData = new FormData();
  formData.append('avatar', fileInput.files[0]);
  formData.append('username', 'alice');

  try {
    const { data } = await axios.post('/api/upload', formData, {
      headers: {
        'Content-Type': 'multipart/form-data'
        // axios sets this automatically too, but being explicit is fine
      }
    });
    console.log('Upload response:', data);
  } catch (error) {
    console.error('Upload failed:', error.response?.data || error.message);
  }
}

Multiple files with FormData

const fileInput = document.querySelector('#multiFileInput'); // <input type="file" multiple>

const formData = new FormData();

// Loop through all selected files
for (const file of fileInput.files) {
  formData.append('photos', file); // same field name for all files
}

const response = await fetch('/api/gallery', {
  method: 'POST',
  body: formData
});

Critical mistake: Do NOT set Content-Type: application/json when sending FormData. The browser must set Content-Type: multipart/form-data; boundary=... automatically. Setting it manually breaks the boundary string and the upload fails silently.


8. Handling Upload Errors Gracefully

Multer error types

const multer = require('multer');

// Multer throws MulterError instances for known errors
// These have a .code property you can switch on

const MULTER_ERROR_MESSAGES = {
  LIMIT_PART_COUNT: 'Too many parts in the form',
  LIMIT_FILE_SIZE: 'File is too large',
  LIMIT_FILE_COUNT: 'Too many files',
  LIMIT_FIELD_KEY: 'Field name is too long',
  LIMIT_FIELD_VALUE: 'Field value is too long',
  LIMIT_FIELD_COUNT: 'Too many fields',
  LIMIT_UNEXPECTED_FILE: 'Unexpected file field'
};

Comprehensive error handling middleware

// Wrap the upload middleware to catch errors per-route
function handleUpload(fieldName) {
  return (req, res, next) => {
    const uploadMiddleware = upload.single(fieldName);

    uploadMiddleware(req, res, (err) => {
      if (err instanceof multer.MulterError) {
        // Multer-specific error
        const messages = {
          LIMIT_FILE_SIZE: 'File exceeds the maximum size of 5 MB',
          LIMIT_FILE_COUNT: 'Too many files uploaded',
          LIMIT_UNEXPECTED_FILE: `Unexpected field name. Expected "${fieldName}"`
        };
        return res.status(400).json({
          error: messages[err.code] || err.message,
          code: err.code
        });
      }

      if (err) {
        // Custom error from fileFilter
        return res.status(400).json({ error: err.message });
      }

      // No error — check if file was actually provided
      if (!req.file) {
        return res.status(400).json({ error: 'No file was uploaded' });
      }

      next();
    });
  };
}

// Usage
app.post('/api/upload', handleUpload('avatar'), (req, res) => {
  res.json({ message: 'Success', file: req.file.originalname });
});

Global error handler approach

// This catches any error that was not handled per-route
app.use((err, req, res, next) => {
  console.error('Unhandled error:', err);

  if (err instanceof multer.MulterError) {
    return res.status(400).json({
      error: 'File upload error',
      details: err.message,
      code: err.code
    });
  }

  res.status(500).json({ error: 'Internal server error' });
});

9. Progress Tracking Concepts

HTTP does not natively support upload progress from the server side, but the browser provides it via XMLHttpRequest or newer APIs:

Using XMLHttpRequest for progress

function uploadWithProgress(file, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    const formData = new FormData();
    formData.append('avatar', file);

    // Track upload progress
    xhr.upload.addEventListener('progress', (event) => {
      if (event.lengthComputable) {
        const percent = Math.round((event.loaded / event.total) * 100);
        onProgress(percent);
      }
    });

    xhr.addEventListener('load', () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(JSON.parse(xhr.responseText));
      } else {
        reject(new Error(`Upload failed: ${xhr.status}`));
      }
    });

    xhr.addEventListener('error', () => reject(new Error('Network error')));

    xhr.open('POST', '/api/upload');
    xhr.send(formData);
  });
}

// Usage
const file = document.querySelector('#fileInput').files[0];
uploadWithProgress(file, (percent) => {
  document.querySelector('#progressBar').style.width = percent + '%';
  document.querySelector('#progressText').textContent = percent + '%';
}).then(data => console.log('Done:', data));

Using axios for progress

const { data } = await axios.post('/api/upload', formData, {
  onUploadProgress: (progressEvent) => {
    const percent = Math.round(
      (progressEvent.loaded * 100) / progressEvent.total
    );
    console.log(`Upload progress: ${percent}%`);
  }
});

Note: Progress tracking happens entirely on the client side. The server (Multer/Express) does not need any special configuration for progress to work.


10. Key Takeaways

  1. A complete upload flow has three parts: HTML form (or FormData), Multer middleware, and a route handler reading req.file / req.files.
  2. req.file is used with upload.single() — it is a single object with properties like originalname, mimetype, size, path, and buffer.
  3. req.files is an array with upload.array() and an object with upload.fields().
  4. Custom filenames prevent collisions — use timestamps, crypto hex, or UUIDs appended to the original extension.
  5. Organize uploads into folders by date, user, or file type for maintainability.
  6. FormData API is how modern frontends send files — never set Content-Type manually when using FormData.
  7. Always handle errors — wrap Multer in a function that catches MulterError and custom errors from fileFilter.
  8. Progress tracking uses XMLHttpRequest.upload.onprogress or axios's onUploadProgress — no server changes needed.

Explain-It Challenge

Can you build a complete file upload flow from scratch — an HTML form, a Multer-powered Express route, and a FormData-based fetch call — all from memory? Try it in under 10 minutes.


Next → 3.7.c — Storage Engines