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.fileandreq.files, custom filenames, organized folder structures, and graceful error handling.
Table of Contents
- 1. Complete Single File Upload Example
- 2. The req.file Object — Every Property Explained
- 3. Complete Multiple File Upload Example
- 4. req.files — Array vs Object
- 5. Custom Filenames with diskStorage
- 6. Organizing Uploads by Date or User
- 7. Frontend — Using FormData API with fetch and axios
- 8. Handling Upload Errors Gracefully
- 9. Progress Tracking Concepts
- 10. Key Takeaways
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
| Property | Storage | Description |
|---|---|---|
fieldname | Both | Form field name (from name attribute) |
originalname | Both | User's original filename |
encoding | Both | File encoding (usually 7bit) |
mimetype | Both | MIME type (image/jpeg, application/pdf, etc.) |
size | Both | File size in bytes |
destination | Disk only | Directory where file was saved |
filename | Disk only | Name of file in the destination |
path | Disk only | Full path: destination + filename |
buffer | Memory only | File 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 │
└──────────────────────────────────────────────────────────────┘
| Method | req.files Type | Access Pattern |
|---|---|---|
upload.array() | Array | req.files[0], req.files.forEach(...) |
upload.fields() | Object | req.files['fieldName'][0] |
upload.any() | Array | Same 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/jsonwhen sending FormData. The browser must setContent-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
- A complete upload flow has three parts: HTML form (or FormData), Multer middleware, and a route handler reading
req.file/req.files. req.fileis used withupload.single()— it is a single object with properties likeoriginalname,mimetype,size,path, andbuffer.req.filesis an array withupload.array()and an object withupload.fields().- Custom filenames prevent collisions — use timestamps, crypto hex, or UUIDs appended to the original extension.
- Organize uploads into folders by date, user, or file type for maintainability.
- FormData API is how modern frontends send files — never set
Content-Typemanually when using FormData. - Always handle errors — wrap Multer in a function that catches
MulterErrorand custom errors fromfileFilter. - Progress tracking uses
XMLHttpRequest.upload.onprogressor axios'sonUploadProgress— 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